On this page

Advanced Customization in Tailwind v4

14 min read TextCh. 5 — Advanced Tailwind

Advanced Customization in Tailwind v4

Tailwind v4 introduces a fundamentally different customization model compared to v3. All configuration moves into your CSS file using first-class directives: @theme, @source, @variant, and @utility. This lesson explores each in depth.

@theme deep dive

@theme registers CSS custom properties as design tokens. Every token you define generates a corresponding set of utility classes.

Naming conventions

The token name determines which utilities are generated:

Token pattern Generated utilities
--color-NAME bg-NAME, text-NAME, border-NAME, ring-NAME, shadow-NAME, fill-NAME, stroke-NAME
--color-NAME-NUMBER bg-NAME-NUMBER, text-NAME-NUMBER, etc.
--font-NAME font-NAME
--text-NAME text-NAME (with --text-NAME--line-height and --text-NAME--letter-spacing sub-properties)
--spacing-NUMBER p-NUMBER, m-NUMBER, w-NUMBER, h-NUMBER, gap-NUMBER, etc.
--breakpoint-NAME NAME: responsive variant prefix
--shadow-NAME shadow-NAME
--radius-NAME rounded-NAME
--animate-NAME animate-NAME
--ease-NAME ease-NAME

Overriding default tokens

You can override any default Tailwind token in @theme:

@theme {
  /* Override the default blue-500 */
  --color-blue-500: oklch(55% 0.22 250);

  /* Override the default border radius */
  --radius-lg: 0.625rem;

  /* Override the default sans font */
  --font-sans: "Inter", ui-sans-serif, sans-serif;

  /* Override the breakpoints entirely */
  --breakpoint-sm: 576px;
  --breakpoint-md: 768px;
  --breakpoint-lg: 992px;
  --breakpoint-xl: 1200px;
  --breakpoint-2xl: 1400px;
}

Resetting the default palette

To start with a completely empty palette (no default colors at all):

@theme {
  /* Reset ALL color tokens */
  --color-*: initial;

  /* Now define only your own colors */
  --color-primary: #6366f1;
  --color-secondary: #f59e0b;
  --color-neutral-50: #f9fafb;
  --color-neutral-900: #111827;
}

The initial keyword clears all tokens matching the --color-* wildcard.

@source

@source tells Tailwind which files to scan for class names. By default, Tailwind auto-detects files in your project. Be explicit when you need to include or exclude specific paths.

@import "tailwindcss";

/* Scan your application source files */
@source "../src/**/*.{html,ts,tsx,jsx,vue,svelte}";

/* Scan a component library in node_modules */
@source "../node_modules/my-ui-lib/dist/**/*.js";

/* Use a glob pattern to exclude test files */
@source "../src/**/!(*.test|*.spec).{ts,tsx}";

Safelisting dynamic classes

If you generate class names dynamically (from a database, API, or user input), Tailwind cannot detect them by scanning. Use @source inline() to safelist specific values:

/* Safelist complete class strings */
@source inline("bg-red-500 bg-green-500 bg-blue-500 bg-yellow-500");

/* Safelist a range using curly-brace expansion */
@source inline("bg-{red,green,blue,yellow,purple}-{500,600,700}");

/* Safelist all indigo shades */
@source inline("{bg,text,border}-indigo-{50,100,200,300,400,500,600,700,800,900}");

@variant

@variant creates custom pseudo-class variant prefixes that you can use anywhere in your HTML.

Basic @variant syntax

/* Simple pseudo-class variant */
@variant hocus {
  &:hover,
  &:focus-visible {
    @slot;
  }
}

/* Data attribute variants */
@variant loading (&[data-loading="true"]);
@variant selected (&[data-selected="true"]);
@variant expanded (&[aria-expanded="true"]);
@variant checked-item (&[aria-checked="true"]);

/* Ancestor-based variants */
@variant sidebar-collapsed (&:where(.sidebar-collapsed *));
@variant theme-minimal (&:where([data-theme="minimal"] *));

After defining these, use them as prefixes in HTML:

<!-- Loading state -->
<button data-loading="true" class="loading:opacity-70 loading:cursor-wait">Save</button>

<!-- Expanded state on an accordion -->
<button aria-expanded="false" class="expanded:text-indigo-700 expanded:bg-indigo-50">
  Toggle section
</button>

<!-- Combined hover + focus -->
<a href="/page" class="hocus:text-indigo-600 hocus:underline transition-colors">
  Navigation link
</a>

@utility

@utility defines custom single-purpose utility classes that integrate fully into Tailwind — they work with every variant and appear in IntelliSense.

/* Scrollbar utilities */
@utility scrollbar-hide {
  scrollbar-width: none;
  &::-webkit-scrollbar { display: none; }
}

@utility scrollbar-thin {
  scrollbar-width: thin;
}

/* Text effects */
@utility text-shadow {
  text-shadow: 0 2px 4px rgb(0 0 0 / 0.12);
}

@utility text-shadow-sm {
  text-shadow: 0 1px 2px rgb(0 0 0 / 0.10);
}

/* Mask utilities */
@utility mask-fade-b {
  mask-image: linear-gradient(to bottom, black 60%, transparent 100%);
}

@utility mask-fade-r {
  mask-image: linear-gradient(to right, black 60%, transparent 100%);
}

/* Layout utilities */
@utility center-abs {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

/* Glass effect */
@utility glass {
  background: rgb(255 255 255 / 0.1);
  backdrop-filter: blur(12px);
  -webkit-backdrop-filter: blur(12px);
  border: 1px solid rgb(255 255 255 / 0.2);
}

Usage with variants:

<!-- Works with every variant -->
<div class="scrollbar-hide hover:scrollbar-thin">Scrollable area</div>
<h1 class="text-shadow dark:text-shadow-sm">Heading with shadow</h1>
<div class="mask-fade-b sm:mask-fade-r">Fades on small, fades right on large</div>
<div class="glass dark:bg-white/5">Glassmorphism card</div>

Performance optimization

CSS output analysis

The Tailwind CLI and Vite plugin both produce optimized, purged CSS output automatically. To analyze what is included:

# Build and check output size
npx @tailwindcss/cli -i ./src/input.css -o ./dist/output.css --minify
ls -lh dist/output.css

A typical production build with 200+ utilities used is around 8–15 KB gzipped.

Lightning CSS features

Tailwind v4's Oxide engine uses Lightning CSS for processing, which automatically:

  • Adds vendor prefixes — no need for Autoprefixer
  • Supports CSS nesting — native & selector nesting works in all browsers
  • Handles custom properties — with proper fallbacks for older browsers
  • Minifies the output — removes whitespace, deduplicates rules, shortens values

Content scanning performance

To make Tailwind's scanner faster, be specific with @source:

/* Too broad (scans everything including node_modules): */
/* @source "../**/*.html" */

/* Better (specific to your source): */
@source "../src/**/*.{html,ts}";
@source "../public/**/*.html";

Integrating Tailwind plugins

In v4, CSS-based plugins use @plugin:

@import "tailwindcss";
@plugin "@tailwindcss/typography";
@plugin "@tailwindcss/forms";
@plugin "@tailwindcss/container-queries";

For JavaScript-based plugins that use the v3 plugin API, they still work via @plugin:

@plugin "./plugins/my-custom-plugin.js";

But the preferred v4 approach is to write your customizations directly in CSS using @theme, @variant, and @utility instead of JavaScript plugins.

Practice

  1. Build a complete design token file in @theme for a SaaS product: brand colors (50–950 scale in OKLCH), semantic surface colors, typography scale, and custom shadows.
  2. Define three @variant rules: loading, selected, and expanded. Use them in an accordion component.
  3. Create three custom @utility classes: scrollbar-hide, glass, and text-balance-pretty (applying both text-wrap: balance and text-wrap: pretty). Use them with hover and dark variants.

@utility replaces the old addUtilities plugin API
In Tailwind v4, use @utility in your CSS file to define custom single-purpose utility classes. They integrate into the utilities layer automatically, work with all variants (hover:, dark:, responsive:), and show up in IntelliSense without any plugin configuration.
OKLCH colors future-proof your design system
OKLCH (Oklab Lightness-Chroma-Hue) is a perceptually uniform color space. Colors with the same L (lightness) value look equally bright across hues — unlike HSL where yellow at 50% L looks much brighter than blue at 50% L. Using OKLCH makes your palette feel more consistent and professional.
Purge only applies to complete class strings
Tailwind's content scanner requires complete, static class name strings in your source files. Template literals like `bg-${color}-500` are not detected. Use a safelist in @source or keep a complete list of class strings in a separate configuration file if you need dynamic class generation.
@import "tailwindcss";

/* ============================================
   @theme: complete design token system
   ============================================ */
@theme {
  /* === Colors === */
  /* Brand palette (generates bg-brand-*, text-brand-*, etc.) */
  --color-brand-50:  oklch(97% 0.015 265);
  --color-brand-500: oklch(57% 0.240 265);
  --color-brand-600: oklch(50% 0.240 265);
  --color-brand-700: oklch(42% 0.210 265);

  /* Semantic surface tokens */
  --color-surface:         oklch(100% 0 0);
  --color-surface-raised:  oklch(98% 0 0);
  --color-on-surface:      oklch(10% 0 0);
  --color-on-surface-muted:oklch(45% 0 0);

  /* === Typography === */
  --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
  --font-display: "Cal Sans", "Inter", sans-serif;
  --font-mono: "Fira Code", ui-monospace, monospace;

  /* Custom text sizes with line-height and letter-spacing */
  --text-display: 4.5rem;
  --text-display--line-height: 1;
  --text-display--letter-spacing: -0.025em;

  /* === Spacing === */
  --spacing-18: 4.5rem;
  --spacing-22: 5.5rem;

  /* === Breakpoints === */
  --breakpoint-xs: 480px;
  --breakpoint-3xl: 1920px;

  /* === Shadows === */
  --shadow-glow: 0 0 24px oklch(57% 0.24 265 / 0.35);
  --shadow-card: 0 1px 3px oklch(0% 0 0 / 0.08), 0 4px 12px oklch(0% 0 0 / 0.06);

  /* === Border radius === */
  --radius-4xl: 2rem;
  --radius-5xl: 2.5rem;

  /* === Animations === */
  --animate-fade-in: fade-in 0.3s ease-out both;
  --animate-slide-up: slide-up 0.4s cubic-bezier(0.16,1,0.3,1) both;
  --animate-scale-in: scale-in 0.2s ease-out both;
}

/* === Custom keyframes === */
@keyframes fade-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes slide-up {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}

@keyframes scale-in {
  from { opacity: 0; transform: scale(0.95); }
  to   { opacity: 1; transform: scale(1); }
}