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.
Al Amin Ahamed
Senior software engineer & AI practitioner. Laravel, PHP, WordPress plugins, WooCommerce extensions.
About me →More from the blog
← Older
React 19 in Production: useTransition, useOptimistic, and the End of forwardRef
Newer →
WordPress Plugin Security: Lessons from Two Disclosures
One email a month. No noise.
What I shipped, what I read, occasional deep dive. Unsubscribe anytime.
Check your inbox — confirmation link sent.