Skip to content

Tailwind v4 Migration: From v3 Config to CSS-First Tokens

A

Al Amin Ahamed

Senior Engineer

8 min read
𝕏 in

Tailwind v4 ships a new architecture: the config moves from JavaScript to CSS, the engine is rewritten in Rust, and the build is ~5× faster. Migrating a real app is straightforward — but there are gotchas the docs gloss over.

I migrated this portfolio (3 Vite bundles, ~140 components) over a weekend. Here's the playbook.

The Big Change

In v3, you wrote:

// tailwind.config.js module.exports = { theme: { extend: { colors: { plum: { 500: '#a78bfa' } }, }, }, };

In v4, you write:

@import "tailwindcss"; @theme { --color-plum-500: #a78bfa; }

That's it. No tailwind.config.js, no PostCSS plugin chain (the v4 plugin is a single @tailwindcss/vite import).

Migration Steps

# 1. Install v4 yarn add tailwindcss@latest @tailwindcss/vite # 2. Remove v3 stuff yarn remove autoprefixer postcss rm postcss.config.js tailwind.config.js

Update vite.config.js:

import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [tailwindcss(), /* ... */], });

In your CSS entry:

@import "tailwindcss"; @theme { /* tokens here */ }

What Breaks

Custom plugin imports. v3 plugins (typography, forms) work via @plugin "@tailwindcss/typography" in CSS:

@plugin "@tailwindcss/typography"; @plugin "@tailwindcss/forms";

Default ring color. v3 defaulted to blue-500; v4 inherits currentColor. Add --default-ring-color to @theme if you want the old behavior.

Container queries. Now first-class — drop @tailwindcss/container-queries.

Arbitrary values. The arbitrary value syntax tightened. bg-[hsl(var(--ink))] still works, but bg-[hsl(var(--ink)/0.5)] (with the slash for opacity) needs to become bg-[hsl(var(--ink))]/50.

The CSS-First Theme

@theme { /* Colors */ --color-ink: #0b0b0d; --color-plum-500: #a78bfa; /* Type scale */ --text-base: 1rem; --text-base--line-height: 1.6; /* Fonts */ --font-sans: 'Inter', system-ui, sans-serif; --font-mono: 'JetBrains Mono', monospace; /* Spacing */ --spacing: 0.25rem; /* base unit, all p-1, m-2, etc. derive from this */ /* Animations */ --animate-spin: spin 1s linear infinite; }

Tokens map directly to utilities. --color-plum-500 becomes bg-plum-500, text-plum-500, border-plum-500. No exporting needed.

Custom Variants

v3 used addVariant in JS. v4 uses CSS:

@variant pointer-fine (@media (pointer: fine)); @variant aria-busy (&[aria-busy="true"]);

Use them like any built-in: pointer-fine:hover:scale-105, aria-busy:opacity-50.

Layer Strategy

The cascade matters more in v4 since theme tokens are CSS variables. My layer order:

@import "tailwindcss"; @import "./tokens.css" layer(theme); @import "./components.css" layer(components); @import "./utilities.css" layer(utilities);

Custom components in the components layer can use Tailwind utilities via @apply and still be overridden by utility classes:

@layer components { .btn-primary { @apply bg-plum-500 text-white px-4 py-2 rounded; } }

Build Time

Before v4 (v3.4 + JIT): 1.8s cold build, 80ms incremental. After v4: 0.4s cold, 20ms incremental.

The 5× number is real. On a large component tree, watching for changes is essentially instant.

What I'd Do Differently

  • Migrate one CSS bundle at a time. v3 and v4 can coexist if you're careful with imports.
  • Don't try to migrate plugins on day one — get the core working first.
  • Keep the v3 config file in git history — useful for diffing token values during the conversion.
Share 𝕏 in
A

Al Amin Ahamed

Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.

About me →

One email a month. No noise.

What I shipped, what I read, occasional deep dive. Unsubscribe anytime.