CSS-first configuration, @layer integration, container queries built-in, the new engine performance, breaking changes, and my honest migration experience from v3 to v4.
I've been using Tailwind CSS since v1.x, back when half the community thought it was an abomination and the other half couldn't stop shipping with it. Every major version has been a significant leap, but v4 is different. It's not just a feature release. It's a ground-up architectural rewrite that changes the fundamental contract between you and the framework.
After migrating two production projects from v3 to v4 and starting three new projects on v4 from scratch, I have a clear picture of what's genuinely better, what's rough, and whether you should migrate today. No hype, no outrage — just what I observed.
Tailwind CSS v4 is three things at once:
tailwind.config.js as the default@layer, container queries, @starting-style, and cascade layers are first-class citizensThe headline you'll see everywhere is "10x faster." That's real, but it undersells the actual change. The mental model for configuring and extending Tailwind has fundamentally shifted. You're working with CSS now, not a JavaScript configuration object that generates CSS.
Here's what a minimal Tailwind v4 setup looks like:
/* app.css — this is the entire setup */
@import "tailwindcss";That's it. No config file. No PostCSS plugin configuration (for most setups). No @tailwind base; @tailwind components; @tailwind utilities; directives. One import, and you're running.
Compare that to v3:
/* v3 — app.css */
@tailwind base;
@tailwind components;
@tailwind utilities;// v3 — tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {},
},
plugins: [],
};// v3 — postcss.config.js
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};Three files reduced to one line. That's not just less boilerplate — it's less surface area for misconfiguration. In v4, content detection is automatic. It scans your project files without needing you to spell out glob patterns.
This is the biggest conceptual shift. In v3, you customized Tailwind through a JavaScript config object:
// v3 — tailwind.config.js
module.exports = {
theme: {
extend: {
colors: {
brand: {
50: "#eff6ff",
500: "#3b82f6",
900: "#1e3a5f",
},
},
fontFamily: {
display: ["Inter Variable", "sans-serif"],
},
spacing: {
18: "4.5rem",
112: "28rem",
},
borderRadius: {
"4xl": "2rem",
},
},
},
};In v4, all of this lives in CSS using the @theme directive:
@import "tailwindcss";
@theme {
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a5f;
--font-display: "Inter Variable", sans-serif;
--spacing-18: 4.5rem;
--spacing-112: 28rem;
--radius-4xl: 2rem;
}At first, I resisted this. I liked having a single JavaScript object where I could see my entire design system. But after a week with the CSS approach, I changed my mind for three reasons:
1. Native CSS custom properties are exposed automatically. Every value you define in @theme becomes a CSS custom property on :root. That means your theme values are accessible in plain CSS, in CSS Modules, in <style> tags, anywhere CSS runs:
/* you get this for free */
:root {
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a5f;
}/* use them anywhere — no Tailwind needed */
.custom-element {
border: 2px solid var(--color-brand-500);
}2. You can use CSS features inside @theme. Media queries, light-dark(), calc() — real CSS works here because it is real CSS:
@theme {
--color-surface: light-dark(#ffffff, #0a0a0a);
--color-text: light-dark(#0a0a0a, #fafafa);
--spacing-container: calc(100vw - 2rem);
}3. Co-location with your other CSS. Your theme, your custom utilities, and your base styles all live in the same language, the same file if you want. There's no context switching between "CSS world" and "JavaScript config world."
In v3 you had theme (replace) vs theme.extend (merge). In v4, the mental model is different:
@import "tailwindcss";
/* This EXTENDS the default theme — adds brand colors alongside existing ones */
@theme {
--color-brand-500: #3b82f6;
}If you want to completely replace a namespace (like removing all default colors), you use @theme with the --color-* wildcard reset:
@import "tailwindcss";
@theme {
/* Clear all default colors first */
--color-*: initial;
/* Now define only your colors */
--color-white: #ffffff;
--color-black: #000000;
--color-brand-50: #eff6ff;
--color-brand-500: #3b82f6;
--color-brand-900: #1e3a5f;
}This wildcard reset pattern is elegant. You pick exactly which parts of the default theme to keep and which to replace. Want all the default spacing but custom colors? Reset --color-*: initial; and leave spacing alone.
For larger projects, you can split your theme across files:
/* styles/theme/colors.css */
@theme {
--color-brand-50: #eff6ff;
--color-brand-100: #dbeafe;
--color-brand-200: #bfdbfe;
--color-brand-300: #93c5fd;
--color-brand-400: #60a5fa;
--color-brand-500: #3b82f6;
--color-brand-600: #2563eb;
--color-brand-700: #1d4ed8;
--color-brand-800: #1e40af;
--color-brand-900: #1e3a5f;
--color-brand-950: #172554;
}
/* styles/theme/typography.css */
@theme {
--font-display: "Inter Variable", sans-serif;
--font-body: "Source Sans 3 Variable", sans-serif;
--font-mono: "JetBrains Mono Variable", monospace;
--text-display: 3.5rem;
--text-display--line-height: 1.1;
--text-display--letter-spacing: -0.02em;
}/* app.css */
@import "tailwindcss";
@import "./theme/colors.css";
@import "./theme/typography.css";This is much cleaner than the v3 pattern of having a giant tailwind.config.js or trying to split it with require().
Tailwind v4's engine is a complete rewrite in Rust. They call it Oxide. I was skeptical of the "10x faster" claim — marketing numbers rarely survive contact with real projects. So I benchmarked it.
My test project: A Next.js app with 847 components, 142 pages, approximately 23,000 Tailwind class usages.
| Metric | v3 (Node) | v4 (Oxide) | Improvement |
|---|---|---|---|
| Initial build | 4,280ms | 387ms | 11x |
| Incremental (edit 1 file) | 340ms | 18ms | 19x |
| Full rebuild (clean) | 5,100ms | 510ms | 10x |
| Dev server start | 3,200ms | 290ms | 11x |
The "10x" claim is conservative for my project. Incremental builds are where it really shines — 18ms means it's essentially instant. You save a file and the browser has the new styles before you can switch tabs.
Three reasons:
1. Rust instead of JavaScript. The core CSS parser, class detection, and code generation are all native Rust. This isn't a "let's rewrite in Rust for fun" situation — CSS parsing is genuinely CPU-bound work where native code has a massive advantage over V8.
2. No PostCSS in the hot path. In v3, Tailwind was a PostCSS plugin. Every build meant: parse CSS into PostCSS AST, run Tailwind plugin, serialize back to CSS string, then other PostCSS plugins run. In v4, Tailwind has its own CSS parser that goes directly from source to output. PostCSS is still supported for compatibility, but the primary path skips it entirely.
3. Smarter incremental processing. The new engine caches aggressively. When you edit a single file, it only re-scans that file for class names and only regenerates the CSS rules that changed. The v3 engine was smarter about this than people give it credit for (JIT mode was already incremental), but v4 takes it much further with fine-grained dependency tracking.
Yes, but not for the reason you'd expect. For most projects, v3's build speed was "fine." You'd wait a few hundred milliseconds in dev. Not painful.
The v4 speed matters because it makes Tailwind invisible in your toolchain. When builds are under 20ms, you stop thinking about Tailwind as a build step at all. It becomes like syntax highlighting — always there, never in the way. That psychological difference is significant over a full day of development.
In v3, Tailwind used its own layer system with @layer base, @layer components, and @layer utilities. These looked like CSS cascade layers but weren't — they were Tailwind-specific directives that controlled where generated CSS appeared in the output.
In v4, Tailwind uses actual CSS cascade layers:
/* v4 output — simplified */
@layer theme, base, components, utilities;
@layer base {
/* reset, preflight */
}
@layer components {
/* your component classes */
}
@layer utilities {
/* all generated utility classes */
}This is a significant change because CSS cascade layers have real specificity implications. A rule in a lower-priority layer always loses to a rule in a higher-priority layer, regardless of selector specificity. That means:
@layer components {
/* specificity: 0-1-0 */
.card { padding: 1rem; }
}
@layer utilities {
/* specificity: 0-1-0 — same specificity but wins because utilities layer is later */
.p-4 { padding: 1rem; }
}Utilities always override components. Components always override base. This is how Tailwind worked conceptually in v3, but now it's enforced by the browser's cascade layer mechanism, not by source order manipulation.
In v3, you defined custom utilities with a plugin API or @layer utilities:
// v3 — plugin approach
const plugin = require("tailwindcss/plugin");
module.exports = {
plugins: [
plugin(function ({ addUtilities }) {
addUtilities({
".text-balance": {
"text-wrap": "balance",
},
".text-pretty": {
"text-wrap": "pretty",
},
});
}),
],
};In v4, custom utilities are defined with the @utility directive:
@import "tailwindcss";
@utility text-balance {
text-wrap: balance;
}
@utility text-pretty {
text-wrap: pretty;
}The @utility directive tells Tailwind "this is a utility class — put it in the utilities layer and allow it to be used with variants." That last part is key. A utility defined with @utility automatically works with hover:, focus:, md:, and every other variant:
<p class="text-pretty md:text-balance">...</p>You can also define custom variants with @variant:
@import "tailwindcss";
@variant hocus (&:hover, &:focus);
@variant theme-dark (.dark &);<button class="hocus:bg-brand-500 theme-dark:text-white">
Click me
</button>This replaces the v3 addVariant plugin API for most use cases. It's less powerful (you can't do programmatic variant generation), but it covers 90% of what people actually do.
Container queries were one of the most requested features in v3. You could get them with the @tailwindcss/container-queries plugin, but it was an add-on. In v4, they're built into the framework.
Mark a container with @container and query its size with the @ prefix:
<!-- mark parent as a container -->
<div class="@container">
<!-- responsive to parent's width, not viewport -->
<div class="flex flex-col @md:flex-row @lg:grid @lg:grid-cols-3">
<div class="p-4">Card 1</div>
<div class="p-4">Card 2</div>
<div class="p-4">Card 3</div>
</div>
</div>The @md, @lg, etc. variants work like responsive breakpoints but are relative to the nearest @container ancestor instead of the viewport. The breakpoint values correspond to Tailwind's default breakpoints:
| Variant | Min-width |
|---|---|
@sm | 24rem (384px) |
@md | 28rem (448px) |
@lg | 32rem (512px) |
@xl | 36rem (576px) |
@2xl | 42rem (672px) |
You can name containers to query specific ancestors:
<div class="@container/sidebar">
<div class="@container/card">
<!-- queries the card container -->
<div class="@md/card:text-lg">...</div>
<!-- queries the sidebar container -->
<div class="@lg/sidebar:hidden">...</div>
</div>
</div>Container queries change how you think about responsive design. Instead of "at this viewport width, show three columns," you say "when this component's container is wide enough, show three columns." Components become truly self-contained. You can move a card component from a full-width layout to a sidebar and it automatically adapts. No media query gymnastics.
I've been refactoring my component libraries to use container queries by default instead of viewport breakpoints. The result is components that work anywhere you place them, without the parent needing to know anything about the component's responsive behavior.
<!-- This component adapts to ANY container it's placed in -->
<article class="@container">
<div class="grid grid-cols-1 @md:grid-cols-[200px_1fr] gap-4">
<img
class="w-full @md:w-auto rounded-lg aspect-video @md:aspect-square object-cover"
src="/post-image.jpg"
alt=""
/>
<div>
<h2 class="text-lg @lg:text-xl font-semibold">Post Title</h2>
<p class="mt-2 text-sm @md:text-base text-gray-600">
Post excerpt goes here...
</p>
<div class="mt-4 hidden @md:flex gap-2">
<span class="text-xs bg-gray-100 px-2 py-1 rounded">Tag</span>
</div>
</div>
</div>
</article>v4 adds several new variants that I've been reaching for constantly. They fill real gaps.
starting: Variant#This maps to CSS @starting-style, which lets you define the initial state of an element when it first appears. This is the missing piece for animating element entry without JavaScript:
<dialog class="opacity-0 starting:opacity-0 open:opacity-100 transition-opacity duration-300">
<p>This dialog fades in when opened</p>
</dialog>The starting: variant generates CSS inside a @starting-style block:
/* what Tailwind generates */
@starting-style {
dialog[open] {
opacity: 0;
}
}
dialog[open] {
opacity: 1;
transition: opacity 300ms;
}This is huge for dialogs, popovers, dropdown menus — anything that needs an entry animation. Before this, you needed JavaScript to add a class on the next frame, or you used @keyframes. Now it's a utility class.
not-* Variant#Negation. Something we've wanted forever:
<!-- every child except the last gets a border -->
<div class="divide-y">
<div class="not-last:pb-4">Item 1</div>
<div class="not-last:pb-4">Item 2</div>
<div class="not-last:pb-4">Item 3</div>
</div>
<!-- style everything that's not disabled -->
<input class="not-disabled:hover:border-brand-500" />
<!-- negate data attributes -->
<div class="not-data-active:opacity-50">...</div>nth-* Variants#Direct nth-child and nth-of-type access:
<ul>
<li class="nth-1:font-bold">First item — bold</li>
<li class="nth-even:bg-gray-50">Even rows — gray bg</li>
<li class="nth-odd:bg-white">Odd rows — white bg</li>
<li class="nth-[3n+1]:text-brand-500">Every third+1 — brand color</li>
</ul>The bracket syntax (nth-[3n+1]) supports any valid nth-child expression. This replaces a lot of custom CSS I used to write for table striping and grid patterns.
in-* Variant (Parent State)#This is the inverse of group-*. Instead of "when my parent (group) is hovered, style me," it's "when I'm inside a parent that matches this state, style me":
<div class="in-data-active:bg-brand-50">
This gets a background when any ancestor has data-active
</div>**: Deep Universal Variant#Style all descendants, not just direct children. This is controlled power — use it sparingly, but it's invaluable for prose content and CMS output:
<!-- all paragraphs inside this div, at any depth -->
<div class="**:data-highlight:bg-yellow-100">
<section>
<p data-highlight>This gets highlighted</p>
<div>
<p data-highlight>So does this, nested deeper</p>
</div>
</section>
</div>Let me be straightforward. If you have a large v3 project, migration is not trivial. Here's what broke in my projects:
Your tailwind.config.js doesn't work out of the box. You need to either:
@theme CSS (recommended for new architecture)@config directive (quick migration path)/* quick migration — keep your old config */
@import "tailwindcss";
@config "../../tailwind.config.js";This @config bridge works, but it's explicitly a migration tool. The recommendation is to move to @theme over time.
Some utilities that were deprecated in v3 are gone:
/* REMOVED in v4 */
bg-opacity-* → use bg-black/50 (slash opacity syntax)
text-opacity-* → use text-black/50
border-opacity-* → use border-black/50
flex-shrink-* → use shrink-*
flex-grow-* → use grow-*
overflow-ellipsis → use text-ellipsis
decoration-slice → use box-decoration-slice
decoration-clone → use box-decoration-clone
If you were already using the modern syntax in v3 (slash opacity, shrink-*), you're fine. If not, these are straightforward find-and-replace changes.
The default color palette shifted slightly. If you depend on exact color values from v3 (not by name but by the actual hex value), you may notice visual differences. The named colors (blue-500, gray-200) still exist but some hex values changed.
v3 required explicit content configuration:
// v3
module.exports = {
content: [
"./src/**/*.{js,ts,jsx,tsx}",
"./components/**/*.{js,ts,jsx,tsx}",
],
};v4 uses automatic content detection. It scans your project root and finds template files automatically. This mostly "just works," but if you have an unusual project structure (monorepo with packages outside the project root, template files in unexpected locations), you may need to configure source paths explicitly:
@import "tailwindcss";
@source "../shared-components/**/*.tsx";
@source "../design-system/src/**/*.tsx";If you wrote custom plugins, the API changed. The addUtilities, addComponents, addBase, and addVariant functions still work through the compatibility layer, but the idiomatic v4 approach is CSS-native:
// v3 plugin
plugin(function ({ addUtilities, theme }) {
addUtilities({
".scrollbar-hide": {
"-ms-overflow-style": "none",
"scrollbar-width": "none",
"&::-webkit-scrollbar": {
display: "none",
},
},
});
});/* v4 — just CSS */
@utility scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}Most first-party plugins (@tailwindcss/typography, @tailwindcss/forms, @tailwindcss/aspect-ratio) have v4-compatible versions. Third-party plugins are hit or miss — check their repo before migrating.
In v3, you could opt out of JIT mode (though almost nobody did). In v4, there is no non-JIT mode. Everything is generated on-demand, always. If you had some reason to use the old AOT (ahead-of-time) engine, that path is gone.
A few variants got renamed or changed behavior:
<!-- v3 -->
<div class="[&>*]:p-4">...</div>
<!-- v4 — the >* part now uses the inset variant syntax -->
<div class="*:p-4">...</div>The arbitrary variant syntax [&...] still works, but v4 provides named alternatives for common patterns.
Here's how I actually migrated, not the happy path from the docs but what the process really looked like.
Tailwind provides a codemod that handles most mechanical changes:
npx @tailwindcss/upgradeThis does a lot automatically:
@tailwind directives to @import "tailwindcss"bg-opacity-50 to bg-black/50)@theme block from your configtheme() calls in JavaScript)For most setups, you'll update your PostCSS config:
// postcss.config.js — v4
module.exports = {
plugins: {
"@tailwindcss/postcss": {},
},
};Note: the plugin name changed from tailwindcss to @tailwindcss/postcss. If you're using Vite, you can skip PostCSS entirely and use the Vite plugin:
// vite.config.ts
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [tailwindcss()],
});This is the manual part. Take your tailwind.config.js theme values and convert them to @theme:
// v3 config — before
module.exports = {
theme: {
extend: {
colors: {
brand: {
light: "#60a5fa",
DEFAULT: "#3b82f6",
dark: "#1d4ed8",
},
},
fontSize: {
"2xs": ["0.65rem", { lineHeight: "1rem" }],
},
animation: {
"fade-in": "fade-in 0.5s ease-out",
},
keyframes: {
"fade-in": {
"0%": { opacity: "0" },
"100%": { opacity: "1" },
},
},
},
},
};/* v4 CSS — after */
@import "tailwindcss";
@theme {
--color-brand-light: #60a5fa;
--color-brand: #3b82f6;
--color-brand-dark: #1d4ed8;
--text-2xs: 0.65rem;
--text-2xs--line-height: 1rem;
--animate-fade-in: fade-in 0.5s ease-out;
}
@keyframes fade-in {
0% { opacity: 0; }
100% { opacity: 1; }
}Notice that keyframes move out of @theme and become regular CSS @keyframes. The animation name in @theme just references them. This is cleaner — keyframes are CSS, they should be written as CSS.
This is non-negotiable. After migration, I opened every page of my app and visually checked it. I also ran my Playwright screenshot tests (if you have them). The codemod is good but not perfect. Things I caught in visual review:
!important overrides that behaved differently with cascade layersCheck every Tailwind-related package:
{
"@tailwindcss/typography": "^1.0.0",
"@tailwindcss/forms": "^1.0.0",
"@tailwindcss/container-queries": "REMOVE — built-in now",
"tailwindcss-animate": "check for v4 support",
"prettier-plugin-tailwindcss": "update to latest"
}The @tailwindcss/container-queries plugin is no longer needed — container queries are built in. Other plugins need their v4-compatible versions.
Since I use Next.js for most projects, here's the specific setup.
Next.js uses PostCSS under the hood, so the PostCSS plugin is the natural fit:
npm install tailwindcss @tailwindcss/postcss// postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};/* app/globals.css */
@import "tailwindcss";
@theme {
--font-sans: "Inter Variable", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono Variable", ui-monospace, monospace;
}That's the complete setup. No tailwind.config.js, no autoprefixer (v4 handles vendor prefixes internally).
One thing that tripped me up: CSS import order matters more in v4 because of cascade layers. Your @import "tailwindcss" should come before your custom styles:
/* correct order */
@import "tailwindcss";
@import "./theme.css";
@import "./custom-utilities.css";
/* your inline @theme, @utility, etc. */If you import custom CSS before Tailwind, your styles may end up in a lower cascade layer and get overridden unexpectedly.
Dark mode works the same way conceptually but the configuration moved to CSS:
@import "tailwindcss";
/* Use class-based dark mode (default is media-based) */
@variant dark (&:where(.dark, .dark *));This replaces the v3 config:
// v3
module.exports = {
darkMode: "class",
};The @variant approach is more flexible. You can define dark mode however you want — class-based, data-attribute-based, or media-query-based:
/* data attribute approach */
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
/* media query — this is the default, so you don't need to declare it */
@variant dark (@media (prefers-color-scheme: dark));If you're using Next.js with Turbopack (which is now the default dev bundler), v4 works great. The Rust engine meshes well with Turbopack's own Rust-based architecture. I measured dev startup times:
| Setup | v3 + Webpack | v3 + Turbopack | v4 + Turbopack |
|---|---|---|---|
| Cold start | 4.8s | 2.1s | 1.3s |
| HMR (CSS change) | 450ms | 180ms | 40ms |
The 40ms HMR for CSS changes is barely perceptible. It feels instant.
The Oxide engine's benefits go beyond raw build speed.
v4 uses significantly less memory. On my 847-component project:
| Metric | v3 | v4 |
|---|---|---|
| Peak memory (build) | 380MB | 45MB |
| Steady-state (dev) | 210MB | 28MB |
This matters for CI/CD pipelines where memory is constrained, and for development machines running ten processes simultaneously.
v4 generates slightly smaller CSS output because the new engine is better at deduplication and dead code elimination:
v3 output: 34.2 KB (gzipped)
v4 output: 29.8 KB (gzipped)
A 13% reduction without changing any code. Not transformative, but free performance.
In v4, if you define a theme value but never use it in your templates, the corresponding CSS custom property is still emitted (it's in @theme, which maps to :root variables). However, the utility classes for unused values are not generated. This is the same as v3's JIT behavior but worth noting: your CSS custom properties are always available, even for values with no utility usage.
If you want to prevent certain theme values from generating CSS custom properties, you can use @theme inline:
@theme inline {
/* These values generate utilities but NOT CSS custom properties */
--color-internal-debug: #ff00ff;
--spacing-magic-number: 3.7rem;
}This is useful for internal design tokens that you don't want exposed as CSS variables.
One pattern that v4 makes significantly easier is multi-brand theming. Because theme values are CSS custom properties, you can swap them at runtime:
@import "tailwindcss";
@theme {
--color-brand: var(--brand-primary, #3b82f6);
--color-brand-light: var(--brand-light, #60a5fa);
--color-brand-dark: var(--brand-dark, #1d4ed8);
}
/* Brand overrides */
.theme-acme {
--brand-primary: #e11d48;
--brand-light: #fb7185;
--brand-dark: #9f1239;
}
.theme-globex {
--brand-primary: #059669;
--brand-light: #34d399;
--brand-dark: #047857;
}<body class="theme-acme">
<!-- all bg-brand, text-brand, etc. use Acme colors -->
<div class="bg-brand text-white">Acme Corp</div>
</body>In v3, this required a custom plugin or complex CSS variable setup outside of Tailwind. In v4, it's natural — the theme is CSS variables, and CSS variables cascade. This is the kind of thing that makes the CSS-first approach feel right.
Let me be balanced. There are things v3 did that I genuinely miss in v4:
1. JavaScript config for programmatic themes. I had a project where we generated color scales from a single brand color using a JavaScript function in the config. In v4, you can't do that in @theme — you'd need a build step that generates the CSS file, or you'd compute the colors once and paste them in. The @config compatibility layer helps, but it's not the long-term story.
2. IntelliSense was better at launch. The v3 VS Code extension had years of polish. v4 IntelliSense works but had some gaps early on — custom @theme values sometimes didn't autocomplete, and @utility definitions weren't always picked up. This has improved substantially with recent updates, but it's worth noting.
3. Ecosystem maturity. The ecosystem around v3 was enormous. Headless UI, Radix, shadcn/ui, Flowbite, DaisyUI — everything was tested against v3. v4 support is rolling out but not universal. I had to submit a PR to one component library to fix v4 compatibility.
Here's my decision framework after living with v4 for several weeks:
shrink-*, etc.)For new projects, v4 is the obvious choice. The CSS-first configuration is cleaner, the engine is dramatically faster, and the new features (container queries, @starting-style, new variants) are genuinely useful.
For existing projects, I recommend a staged approach:
The migration is not painful if you prepare for it. The codemod handles 80% of the work. The remaining 20% is manual but straightforward. Budget a day for a medium project, two to three days for a large one.
Tailwind v4 is what Tailwind should have been all along. The JavaScript configuration was always a concession to the tooling of its time. CSS-first configuration, native cascade layers, a Rust engine — these aren't trends, they're the framework catching up to the platform. The web platform got better, and Tailwind v4 leans into it instead of fighting it.
The move to write your design tokens in CSS, compose them with CSS features, and let the browser's own cascade handle specificity — that's the right direction. It took four major versions to get here, but the result is the most coherent version of Tailwind yet.
Start your next project with it. You won't look back.