References

Tailwind CSS v4 Migration Guide

CSS-first configuration using @theme blocks, OKLCH colors, size-* utilities, and @custom-variant dark mode replace JavaScript config patterns.


Core Patterns

  • CSS-First Configuration
  • @theme Blocks
  • OKLCH Color Space
  • New Utilities

CSS-First Configuration

Before (v3): JavaScript Config

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: {
        primary: '#4f46e5',
        secondary: '#9333ea',
      },
      fontFamily: {
        sans: ['Inter', 'sans-serif'],
      },
      spacing: {
        '128': '32rem',
      },
    },
  },
  plugins: [],
};
/* globals.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

After (v4): CSS-First

/* globals.css */
@import 'tailwindcss';

@theme {
  /* Colors */
  --color-primary: #4f46e5;
  --color-secondary: #9333ea;

  /* Typography */
  --font-family-sans: Inter, sans-serif;

  /* Spacing */
  --spacing-128: 32rem;
}

Benefits:

  • No JavaScript config needed (unless using plugins)
  • Faster builds (CSS parsing > JS execution)
  • Better IDE support (CSS IntelliSense)
  • Clearer source of truth (all in CSS)

@theme Blocks

Define all customization in @theme blocks.

Colors

@theme {
  /* Brand colors (OKLCH recommended) */
  --color-primary: oklch(60% 0.2 260);
  --color-secondary: oklch(70% 0.15 300);
  --color-accent: oklch(65% 0.18 140);

  /* Or hex (converted to OKLCH internally) */
  --color-brand: #4f46e5;
}

/* Usage */
<div class="bg-primary text-secondary">Content</div>

Typography

@theme {
  --font-family-sans: Inter, ui-sans-serif, system-ui, sans-serif;
  --font-family-serif: Merriweather, ui-serif, Georgia, serif;
  --font-family-mono: 'Fira Code', ui-monospace, monospace;

  /* Font sizes */
  --font-size-xs: 0.75rem;
  --font-size-sm: 0.875rem;
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;
  --font-size-xl: 1.25rem;
}

Spacing

@theme {
  --spacing-0: 0px;
  --spacing-1: 0.25rem;
  --spacing-2: 0.5rem;
  /* ... */
  --spacing-128: 32rem;
}

Breakpoints

@theme {
  --breakpoint-sm: 40rem;    /* 640px */
  --breakpoint-md: 48rem;    /* 768px */
  --breakpoint-lg: 64rem;    /* 1024px */
  --breakpoint-xl: 80rem;    /* 1280px */
  --breakpoint-2xl: 96rem;   /* 1536px */
}

OKLCH Color Space

OKLCH provides better perceptual uniformity than HSL/RGB.

Syntax

oklch(L% C H)

L = Lightness (0-100%)
C = Chroma (saturation, 0-0.4 typical)
H = Hue (0-360 degrees)

Examples

@theme {
  /* Blue spectrum */
  --color-blue-light: oklch(70% 0.2 260);
  --color-blue-base: oklch(60% 0.2 260);
  --color-blue-dark: oklch(40% 0.2 260);

  /* Green spectrum */
  --color-green-light: oklch(75% 0.15 140);
  --color-green-base: oklch(60% 0.15 140);
  --color-green-dark: oklch(40% 0.15 140);

  /* Neutral gray (C = 0) */
  --color-gray-50: oklch(98% 0 0);
  --color-gray-100: oklch(96% 0 0);
  /* ... */
  --color-gray-900: oklch(20% 0 0);
  --color-gray-950: oklch(15% 0 0);
}

Why OKLCH?

FeatureHSLRGBOKLCH
Perceptual uniformity
Predictable lightness
Better gradients
Wide gamut (P3)

Example: Gradient

/* HSL: Muddy midtone */
background: linear-gradient(to right, hsl(0, 100%, 50%), hsl(240, 100%, 50%));

/* OKLCH: Smooth, vibrant */
background: linear-gradient(to right, oklch(60% 0.2 25), oklch(60% 0.2 260));

New Utilities

size-* Shorthand

Replaces separate w-* and h-* utilities.

<!-- v3: Separate width/height -->
<div class="w-10 h-10"></div>
<div class="w-full h-screen"></div>

<!-- v4: Single size utility -->
<div class="size-10"></div>
<div class="size-full"></div>

When to use:

  • Square elements (icons, avatars, buttons)
  • Full-width/height containers

When to use separate w/h:

  • Rectangular elements (cards, images)
  • Different responsive sizing (w-full md:w-1/2 h-64 md:h-96)

Improved Spacing

More intuitive spacing values aligned with rem units.

/* v4: Clearer rem-based spacing */
--spacing-0: 0rem;
--spacing-px: 1px;
--spacing-0.5: 0.125rem;  /* 2px */
--spacing-1: 0.25rem;     /* 4px */
--spacing-1.5: 0.375rem;  /* 6px */
--spacing-2: 0.5rem;      /* 8px */
/* ... */

Native Dark Mode

Use @custom-variant for dark mode instead of config-based approach.

Before (v3): Config-Based

// tailwind.config.js
module.exports = {
  darkMode: 'class',
};
<div class="bg-white dark:bg-gray-900">Content</div>

After (v4): @custom-variant

@custom-variant dark (&:where(.dark, .dark *));

/* Usage: Same as v3 */
<div class="bg-white dark:bg-gray-900">Content</div>

Why better?

  • No config needed
  • More flexible (can create custom variants)
  • Consistent with CSS-first approach

Custom Variants Example

/* Reduced motion variant */
@custom-variant motion-safe (&:where(:not([data-motion="reduce"])));

/* High contrast variant */
@custom-variant high-contrast (&:where([data-contrast="high"], [data-contrast="high"] *));

/* Usage */
<div class="transition motion-safe:duration-300">Animated</div>
<div class="border high-contrast:border-2">High contrast border</div>

Animation in @theme

Place @keyframes inside @theme blocks for tree-shakeable animations.

Before (v3): Config + Global Keyframes

// tailwind.config.js
module.exports = {
  theme: {
    extend: {
      keyframes: {
        fadeIn: {
          '0%': { opacity: '0' },
          '100%': { opacity: '1' },
        },
      },
      animation: {
        fadeIn: 'fadeIn 0.3s ease-in',
      },
    },
  },
};
/* All @keyframes always in bundle */
@keyframes fadeIn { /* ... */ }

After (v4): @theme with Tree-Shaking

@theme {
  --animate-fade-in: fade-in 0.3s ease-in;
  --animate-slide-up: slide-up 0.3s ease-out;
  --animate-spin: spin 1s linear infinite;

  @keyframes fade-in {
    from { opacity: 0; }
    to { opacity: 1; }
  }

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

  @keyframes spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
  }
}

/* Only referenced @keyframes included in build */

Benefits:

  • Tree-shaking: Unused animations removed
  • Scoped: No global namespace pollution
  • Smaller bundles: Only ship what you use

Migration Checklist

Step 1: Update Dependencies

npm install -D tailwindcss@next
# or
pnpm add -D tailwindcss@next

Step 2: Replace @tailwind with @import

/* ❌ OLD: v3 syntax */
@tailwind base;
@tailwind components;
@tailwind utilities;

/* ✅ NEW: v4 syntax */
@import 'tailwindcss';

Step 3: Move theme.extend to @theme

// ❌ OLD: tailwind.config.js
module.exports = {
  theme: {
    extend: {
      colors: { primary: '#4f46e5' },
    },
  },
};
/* ✅ NEW: @theme block */
@theme {
  --color-primary: #4f46e5;
}
/* Before: Hex/RGB */
@theme {
  --color-primary: #4f46e5;
}

/* After: OKLCH (better gradients, perceptual uniformity) */
@theme {
  --color-primary: oklch(60% 0.2 260);
}

Conversion tool: https://oklch.com/

Step 5: Move @keyframes into @theme

/* ❌ OLD: Global @keyframes */
@keyframes fadeIn {
  from { opacity: 0; }
  to { opacity: 1; }
}

/* ✅ NEW: @keyframes in @theme */
@theme {
  --animate-fade-in: fade-in 0.3s ease-in;

  @keyframes fade-in {
    from { opacity: 0; }
    to { opacity: 1; }
  }
}

Step 6: Update Dark Mode to @custom-variant

// ❌ OLD: tailwind.config.js
module.exports = {
  darkMode: 'class',
};
/* ✅ NEW: @custom-variant */
@custom-variant dark (&:where(.dark, .dark *));

Step 7: Test Build

npm run build
# Verify all utilities work
# Check dark mode
# Test animations

Step 8: Remove tailwind.config.js (Optional)

If you’ve moved everything to CSS:

rm tailwind.config.js

Keep if:

  • Using plugins (still require config)
  • Complex content paths
  • Custom safelist

Breaking Changes

Removed Utilities

v3 Utilityv4 Replacement
w-{n} h-{n} (for squares)size-{n}
divide-*Use border-* on children
ring-offset-* (deprecated colors)Use semantic color tokens

Config Changes

v3v4
tailwind.config.js theme@theme blocks in CSS
darkMode: "class"@custom-variant dark
@tailwind directives@import 'tailwindcss'

Plugin API Changes

Plugins still use tailwind.config.js, but with updated API:

// v4 plugin example
const plugin = require('tailwindcss/plugin');

module.exports = {
  plugins: [
    plugin(function({ addUtilities, theme }) {
      addUtilities({
        '.scrollbar-hide': {
          '-ms-overflow-style': 'none',
          'scrollbar-width': 'none',
          '&::-webkit-scrollbar': {
            display: 'none',
          },
        },
      });
    }),
  ],
};

Common Patterns

Full v4 Setup

/* globals.css */
@import 'tailwindcss';

/* Dark mode variant */
@custom-variant dark (&:where(.dark, .dark *));

/* Theme configuration */
@theme {
  /* Colors (OKLCH) */
  --color-primary: oklch(60% 0.2 260);
  --color-secondary: oklch(70% 0.15 300);
  --color-background: oklch(100% 0 0);
  --color-foreground: oklch(15% 0 0);

  /* Dark mode */
  @media (prefers-color-scheme: dark) {
    --color-background: oklch(15% 0 0);
    --color-foreground: oklch(95% 0 0);
  }

  /* Typography */
  --font-family-sans: Inter, system-ui, sans-serif;
  --font-size-base: 1rem;
  --font-size-lg: 1.125rem;

  /* Spacing */
  --spacing-128: 32rem;

  /* Animations */
  --animate-fade-in: fade-in 0.3s ease-in;

  @keyframes fade-in {
    from { opacity: 0; }
    to { opacity: 1; }
  }
}

Minimal Config (Plugins Only)

// tailwind.config.js (only if using plugins)
module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  plugins: [
    require('@tailwindcss/typography'),
  ],
};

Troubleshooting

Issue: Utilities Not Working

Cause: Missing @import 'tailwindcss'

Fix:

/* Add to main CSS file */
@import 'tailwindcss';

Issue: Dark Mode Not Working

Cause: Missing @custom-variant dark

Fix:

@custom-variant dark (&:where(.dark, .dark *));

Issue: Animations Not Compiling

Cause: @keyframes outside @theme block

Fix:

/* ❌ WRONG */
@keyframes spin { /* ... */ }

/* ✅ CORRECT */
@theme {
  @keyframes spin { /* ... */ }
}

Issue: Custom Colors Not Available

Cause: Missing --color- prefix

Fix:

/* ❌ WRONG */
@theme {
  primary: #4f46e5;
}

/* ✅ CORRECT */
@theme {
  --color-primary: #4f46e5;
}

Container Queries

Native container queries replace the need for media-query workarounds. Components respond to their parent container’s size, not the viewport.

Enable Container Queries

<!-- ✅ Mark parent as a container -->
<div class="@container">
  <!-- Child uses @{size}: variants -->
  <div class="grid grid-cols-1 @md:grid-cols-2 @lg:grid-cols-3">
    <!-- 1 column by default, 2 at container ≥28rem, 3 at ≥32rem -->
  </div>
</div>

Named Containers

<!-- ✅ Named containers for nested container queries -->
<div class="@container/card">
  <div class="@container/sidebar">
    <p class="text-sm @md/card:text-base @lg/card:text-lg">
      Text size responds to "card" container, not "sidebar"
    </p>
  </div>
</div>

Container Query Sizes

VariantMin-width
@xs:20rem (320px)
@sm:24rem (384px)
@md:28rem (448px)
@lg:32rem (512px)
@xl:36rem (576px)
@2xl:42rem (672px)

Custom Container Size

@theme { --container-card: 400px; } /* enables @card: variant */

vs Media Queries

Use container queries for reusable components (cards, sidebars, widgets) that may appear in different contexts. Use media queries for page-level layout changes.


@utility Directive

Register custom utility classes with full Tailwind integration (variants, responsive, hover, etc.).

Before (v3): @layer utilities

/* ❌ v3 approach — less integrated */
@layer utilities {
  .scrollbar-hide {
    -ms-overflow-style: none;
    scrollbar-width: none;
  }
  .scrollbar-hide::-webkit-scrollbar {
    display: none;
  }
}

After (v4): @utility

/* ✅ v4 — works with all Tailwind variants */
@utility scrollbar-hide {
  -ms-overflow-style: none;
  scrollbar-width: none;
  &::-webkit-scrollbar {
    display: none;
  }
}
<!-- ✅ Now supports hover:, focus:, dark:, responsive prefixes -->
<div class="scrollbar-hide hover:scrollbar-hide md:scrollbar-hide">...</div>

Custom Utilities with Values

/* ✅ Custom utility that accepts Tailwind spacing values */
@utility tab-size-* {
  tab-size: --value(--spacing-*);
}
<pre class="tab-size-4">  indented code</pre>

When to Use @utility vs @apply

Custom single-property utilities → @utility
Reusable component styles (multiple properties) → @apply in @layer components
One-off styles → inline Tailwind utilities

starting: Variant (Enter Animations)

The starting: variant applies styles before an element’s first paint — enabling CSS-only enter animations.

Basic Enter Animation

<!-- ✅ Element enters with fade-in from below -->
<div class="
  opacity-100 translate-y-0 transition duration-300
  starting:opacity-0 starting:translate-y-2
">
  Animates in on first render
</div>

Dialog / Modal Enter

<!-- ✅ CSS-only dialog enter animation -->
<dialog open class="
  opacity-100 scale-100 transition duration-200
  starting:opacity-0 starting:scale-95
">
  <p>Dialog content</p>
</dialog>

Toast Notification

<!-- ✅ Toast slides up on appear -->
<div class="
  translate-y-0 opacity-100 transition-all duration-300 ease-out
  starting:translate-y-4 starting:opacity-0
">
  Item saved successfully
</div>

Browser support: Chrome 117+, Firefox 129+, Safari 17.5+. For wider support, use JavaScript-controlled class toggling.