Tailwind CSS v4
21. June, 2025 • 12 min read • Develop
Configuration that lives in the stylesheet
Tailwind v4 landed in January 2025 and it is a rewrite, not a refresh. The JavaScript config file is gone from the happy path, the engine underneath is new, and a pile of utilities have been renamed. If you have a v3 project, none of that is optional reading: the upgrade is mechanical in places and genuinely disruptive in others, and the disruptive parts are the ones nobody mentions in the release notes summary.
I have a soft spot for the direction here. Moving the design tokens into CSS custom properties is the same argument I made in CSS custom properties back in 2022, only now the framework agrees with me. What I am less thrilled about is what v4 asks you to give up to get there. More on that at the end.
The engine
The headline is the new engine, written from scratch. The numbers from the Tailwind team’s own benchmarks:
| Build type | v3.4 | v4.0 | Improvement |
|---|---|---|---|
| Full build | 378ms | 100ms | 3.78x |
| Incremental rebuild, new CSS | 44ms | 5ms | 8.8x |
| Incremental rebuild, no new CSS | 35ms | 192µs | 182x |
The bottom row is the one that changes how the tool feels. 192 microseconds is below the threshold where you notice anything at all, so an incremental rebuild that produces no new classes is effectively free. Most keystrokes in most sessions fall into that row, because you spend far more time nudging existing classes around than inventing new ones.
I would treat the first row with the usual scepticism you apply to any benchmark shipped by the people who wrote the thing being benchmarked. It is a real improvement, and your project is not their project.
Configuration moved into CSS
Here is v3, where the theme lives in a JavaScript module that the build tool has to load:
// tailwind.config.js
module.exports = {
content: ['./src/**/*.{html,js,tsx}'],
theme: {
extend: {
colors: {
brand: '#ff5733',
accent: '#3498db',
},
fontFamily: {
display: ['Satoshi', 'sans-serif'],
},
spacing: {
18: '4.5rem',
},
},
},
};And here is v4, where it lives in the stylesheet:
@import "tailwindcss";
@theme {
--font-display: "Satoshi", "sans-serif";
--color-brand: #ff5733;
--color-accent: #3498db;
--spacing-18: 4.5rem;
--breakpoint-3xl: 1920px;
--ease-fluid: cubic-bezier(0.3, 0, 0, 1);
}The naming is not arbitrary. The prefix of each variable tells Tailwind which family of utilities to generate: --color-* produces bg-brand, text-brand, border-brand and the rest; --breakpoint-* produces 3xl: variants; --ease-* produces ease-fluid. Learn the prefixes and the config stops needing documentation.
Everything in @theme is also emitted as a real custom property on :root. That means a value you defined for Tailwind is available to hand-written CSS, to inline styles, to a charting library that wants a hex string, and to getComputedStyle in a test. In v3, getting a theme value into JavaScript meant importing the config and running it through resolveConfig, which pulled the whole config into your bundle if you were careless. Now it is var(--color-brand) and there is no build step involved.
If you still need JavaScript for something, the config file is supported. It is no longer discovered automatically, so you point at it:
@import "tailwindcss";
@config "../../tailwind.config.js";Plugins load the same way, with @plugin "@tailwindcss/typography"; in the stylesheet.
Setting it up
Two dependencies:
npm install tailwindcss @tailwindcss/postcssOne PostCSS plugin:
// postcss.config.mjs
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};One import:
/* main.css */
@import "tailwindcss";That is the whole setup. The three @tailwind base/components/utilities directives are gone, and so are postcss-import and autoprefixer as separate installs, since the Tailwind plugin now handles imports and prefixing itself.
Content detection is automatic. Tailwind walks your project, skips anything in .gitignore, and ignores binary files, so the content array has no successor. When the heuristic guesses wrong, which it does for template files that live outside the project root or inside a linked package, you tell it explicitly:
@import "tailwindcss";
@source "../../packages/ui/src";On Vite, skip PostCSS entirely and use the first-party plugin, which is measurably quicker because it hooks into Vite’s own module graph instead of running as a post-processing step:
npm install tailwindcss @tailwindcss/vite// vite.config.js
import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [tailwindcss()],
});What v4 takes from modern CSS
Tailwind v3 was written for a browser landscape that no longer exists. v4 assumes Safari 16.4, Chrome 111 and Firefox 128 as its floor and spends that budget freely.
Cascade layers
Tailwind’s own output now sits inside real @layer rules. In v3 the ordering was emulated, and the emulation leaked whenever a third-party stylesheet had opinions about specificity. Now the browser does the ordering, which mostly means you stop losing arguments with a component library’s reset.
Container queries, no plugin
The container query plugin is now part of the core:
<div class="@container">
<div class="grid grid-cols-1 @sm:grid-cols-3 @lg:grid-cols-4">
<!-- Responsive based on container width -->
</div>
</div>There are @max-* variants too, and you can stack them into a range with @min-md:@max-xl:hidden. I wrote about why this matters for component-driven layouts in CSS container queries; the short version is that a card should respond to the column it is in, not to the window.
OKLCH and color-mix
The default palette moved from sRGB to OKLCH, which gives more saturated colours on wide-gamut displays and, more usefully, makes lightness steps behave consistently across hues. Opacity modifiers are implemented with color-mix(), so they now work on any colour value at all, including custom properties and currentColor:
<div class="bg-brand/50">
<!-- 50% opacity of your custom brand color -->
</div>
<div class="bg-(--my-custom-color)/75">
<!-- Works with any CSS variable too -->
</div>Registered custom properties
Tailwind registers its animatable properties with @property, which tells the browser their type. A browser that knows --tw-gradient-from is a colour can interpolate it; a browser that thinks it is an arbitrary string cannot. This is what makes transitions on gradients and individual transform components work.
New utilities worth knowing
3D transforms are built in now:
<div class="perspective-500">
<div class="rotate-x-12 rotate-y-6 transform-3d">
<!-- 3D transformed element -->
</div>
</div>The set includes rotate-x-*, rotate-y-*, scale-z-*, translate-z-*, perspective-* and perspective-origin-*.
Gradients gained angles, radial and conic variants, and interpolation control:
<!-- Arbitrary angle -->
<div class="bg-linear-45 from-blue-500 to-purple-500"></div>
<!-- Radial gradient (new) -->
<div class="bg-radial from-yellow-300 to-orange-500"></div>
<!-- Conic gradient (new) -->
<div class="bg-conic from-red-500 via-green-500 to-blue-500"></div>
<!-- More vivid gradients via OKLCH interpolation -->
<div class="bg-linear-to-r/oklch from-blue-500 to-green-500"></div>bg-gradient-* is now bg-linear-*, which the upgrade tool renames for you.
Numeric utilities take any value without arbitrary-value brackets, which quietly removes a category of ugly class names:
<!-- v3: needed arbitrary values -->
<div class="grid-cols-[15] mt-[4.25rem]"></div>
<!-- v4: just works -->
<div class="grid-cols-15 mt-17 w-29"></div>The not-* variant negates a pseudo-class, a media query or a @supports condition:
<button class="opacity-100 not-hover:opacity-75">
Subtle opacity when not hovered
</button>
<div class="not-supports-[display:grid]:flex">
Fallback for browsers without grid
</div>And starting: exposes @starting-style, which is how you do enter transitions without a JavaScript library holding a class on for one frame:
<div popover id="update-panel" class="transition-discrete starting:open:opacity-0">
<!-- Fades in when opened, no JavaScript needed -->
</div>A handful of smaller additions that I keep forgetting exist and then rediscovering: inset-shadow-* and inset-ring-* stack up to four shadow layers on one element, field-sizing makes a textarea grow with its content without a resize observer, color-scheme fixes the white scrollbar on a dark page, font-stretch drives variable-font width axes, and the nth-*, inert and in-* variants cover cases that used to need a custom selector.
The breaking changes that will actually bite
The migration tool handles the renames. These are the ones that survive it.
The scale shift. Shadows, radii and blurs all moved down a step, so a bare shadow is now shadow-sm and the old shadow-sm is shadow-xs:
| v3 | v4 |
|---|---|
shadow-sm |
shadow-xs |
shadow |
shadow-sm |
rounded-sm |
rounded-xs |
rounded |
rounded-sm |
blur-sm |
blur-xs |
blur |
blur-sm |
The same shift applies to drop-shadow-* and backdrop-blur-*. Codemods catch these in class attributes. They do not catch class names assembled at runtime from a variable, which is exactly where they will hide.
Defaults that changed. Default border colour is currentColor instead of gray-200, so every border with no colour beside it now inherits the text colour, which on a dark heading looks like someone drew on your card with a marker. Default ring width dropped from 3px to 1px and its colour from blue-500 to currentColor. outline-none was renamed to outline-hidden and the name outline-none was reused for outline-style: none, so the old class still compiles and no longer does what it did.
Hover. The hover: variant only applies where the primary input actually supports hovering. Sticky hover states on touch devices are gone, which is a fix, but if your design relied on a hover style being visible after a tap you will notice.
Variant stacking reversed. Stacked variants read left to right now, so first:*:pt-0 becomes *:first:pt-0.
Opacity utilities removed. bg-opacity-*, text-opacity-* and friends are gone in favour of the slash modifier:
<!-- v3 -->
<div class="bg-black bg-opacity-50"></div>
<!-- v4 -->
<div class="bg-black/50"></div>Custom utilities. The @layer utilities pattern is replaced by @utility:
/* v3 */
@layer utilities {
.custom-scrollbar {
scrollbar-width: thin;
}
}
/* v4 */
@utility custom-scrollbar {
scrollbar-width: thin;
}Variables in arbitrary values. bg-[--brand-color] became bg-(--brand-color), because square brackets now unambiguously mean “a literal value” and parentheses mean “a variable”.
And the big one: no preprocessors. Tailwind v4 does not run alongside Sass, Less or Stylus. Tailwind is the preprocessor now, handling imports, nesting and prefixing itself. If your project has a .scss file with mixins in it, you rewrite those styles before you upgrade, not after.
Migrating
Do it on a branch, because the diff is large and you will want to bisect it:
git checkout -b tailwind-v4-migration
npx @tailwindcss/upgradeThe tool needs Node 20 or later. It converts tailwind.config.js into @theme, renames deprecated utilities in your templates, and rewrites your PostCSS or Vite config. Then read the diff properly, and pay attention to four things.
- Anything that builds class names at runtime. Template literals, lookup objects mapping a prop to a class,
clsxcalls with conditional fragments. The codemod sees strings, not intent. - Dark mode. The
darkMode: 'class'option has no direct equivalent; you declare it as a variant:@custom-variant dark (&:where(.dark, .dark *)); - The
containerutility. Itscenterandpaddingoptions are gone. Redefine it with@utility containerif you relied on them. - Spacing between siblings.
space-y-*changed its selector from> :not([hidden]) ~ :not([hidden])to> :not(:last-child), and the margin moved from the top of the following element to the bottom of the preceding one. Layouts with a hidden first child will shift.
What the tool cannot do at all: rewrite your Sass, port a plugin that reaches into internals, or update snapshot tests that assert on generated CSS.
Who should wait
New project, no legacy: use v4, there is nothing to weigh up.
An existing project splits along one line, and it is not size. It is whether you have Sass. A large v4 migration without Sass is a long afternoon of renames and a careful look at the diff. A small project built on Sass mixins is a rewrite of your styling layer wearing a migration’s clothes, and the upgrade tool will not help with any of it.
The browser floor matters if you have users you cannot choose. Safari 16.4 is March 2023, so this is not aggressive, but “not aggressive” and “acceptable to your client’s compliance team” are different standards.
Component libraries are mostly fine. shadcn/ui, Headless UI and Radix all support v4. Smaller plugins are the risk, though it is worth correcting a claim I have seen repeated in a few places: the JavaScript plugin API was not removed. @plugin still loads a v3-style plugin. Plugins that dig into Tailwind’s internal config resolution are the ones that break, and that is a much smaller set.
What I’m doing about this site
Nothing, for now, and for a boring reason. This blog runs on Bootstrap 5 with a pile of hand-written SCSS, compiled by gatsby-plugin-sass. Tailwind v4 will not sit next to that, and I am not rewriting a working stylesheet to change which utility framework I am not using.
That is the part of this release I find genuinely awkward. The CSS-first configuration is good. The engine is fast. But “we are your preprocessor now” is a much bigger claim than “we renamed some shadows”, and it is the one that decides whether you can adopt v4 at all. Projects that started on Tailwind will not notice. Projects that grew a Sass layer over five years get to choose between two rewrites, and I suspect a lot of them will simply stay on v3 until something forces the issue 🎨
‘Till next time!