Checked against Tailwind CSS 4.2.2 on 2026-08-20.
Tailwind v4's headline change is not a new utility. It is that configuration moved out of tailwind.config.js and into your CSS file. Your theme is now a block of CSS custom properties. Dark mode is a custom variant. Plugins are an @plugin line. A JavaScript config still works, but it is no longer picked up automatically; you have to point at it.
That reshapes most of the advice written about Tailwind between 2021 and 2024, including the earlier version of this post, which explained v3's mental model while quoting v4 syntax. This is the corrected version: what the model is now, what the migration breaks, the browsers it costs you, and how big the CSS actually gets on a real site.
If you are pairing it with a framework, the Next.js App Router guide covers the other half of the setup.
The Utility-First Model, Briefly
Bootstrap gives you .btn and .card. Tailwind gives you the properties and you compose:
<button class="bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-blue-700 transition-colors">
Get Started
</button>htmlEach class does one thing. The arguments in its favour are real and they are mostly about deletion: no names to invent, no orphaned CSS after you delete a component, and the styles are visible where the element is rather than in a file three directories away. The values come from a token set you control, so text-blue-600 is the same blue everywhere.
This is not inline styles. Utilities support variants, pseudo-classes, media queries and container queries, and only the classes you use are generated.
Setup, v4 Edition
npm install -D tailwindcss @tailwindcss/postcss postcssbash// postcss.config.mjs
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;js/* globals.css */
@import "tailwindcss";cssTwo things changed here from every v3 tutorial. The PostCSS plugin moved from the tailwindcss package to @tailwindcss/postcss, and the three @tailwind base/components/utilities directives are replaced by a single @import "tailwindcss". There is also no content array to maintain: v4 detects source files itself.
Your Theme Is CSS Now
@theme declares design tokens as custom properties, and Tailwind generates utilities from them:
@theme {
--font-display: "Satoshi", sans-serif;
--breakpoint-3xl: 1920px;
--color-neon-pink: oklch(71.7% 0.25 360);
}cssAdding --breakpoint-3xl gives you 3xl: variants. Adding --color-neon-pink gives you bg-neon-pink, text-neon-pink, border-neon-pink. The token is the configuration.
To replace the default scale rather than extend it, reset it first:
@theme {
--breakpoint-*: initial;
--breakpoint-tablet: 40rem;
--breakpoint-laptop: 64rem;
--breakpoint-desktop: 80rem;
}cssThis site uses the inline form, which maps Tailwind's token names onto variables defined elsewhere. That indirection is what lets the same utility resolve to a different value per theme:
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-muted-foreground: var(--muted-foreground);
}
:root {
--background: oklch(0.98 0 0);
--foreground: oklch(0.145 0 0);
--muted-foreground: oklch(0.5 0 0);
}cssNote @plugin on line three. Official plugins are loaded from CSS in v4, not from a plugins array in a JavaScript file.
Dark Mode Is a Variant, Not a Config Key
By default dark: follows the prefers-color-scheme media query. If you want a class-controlled toggle, v3's answer was darkMode: "class" in the config. That key does not exist in v4. You redefine the variant:
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));cssThis is the single change that catches most people mid-migration, because the old config silently does nothing and the toggle silently does not work.
Responsive Design and Container Queries
Breakpoint prefixes are mobile-first: an unprefixed utility applies everywhere, a prefixed one applies at that width and up.
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"></div>html| Prefix | Min width |
|---|---|
sm | 640px |
md | 768px |
lg | 1024px |
xl | 1280px |
2xl | 1536px |
Container queries are in core in v4. There is no plugin to install any more, which is worth knowing because most tutorials still tell you to add @tailwindcss/container-queries:
<div class="@container">
<div class="flex flex-col @8xl:flex-row">
<!-- responds to the container's width, not the viewport -->
</div>
</div>htmlThis is the better tool for a component that appears in a sidebar on one page and full width on another. Viewport breakpoints cannot express that; the component does not know where it is.
State Variants
<input
class="border border-gray-300 rounded-lg px-4 py-2
focus:outline-none focus:ring-2 focus:ring-blue-500
disabled:bg-gray-100 disabled:cursor-not-allowed"
type="text"
/>html<div class="group cursor-pointer">
<h3 class="group-hover:text-blue-600 transition-colors">Hover the card</h3>
<p class="group-hover:text-gray-700">This text also changes.</p>
</div>htmlgroup-* styles a child from a parent's state; peer-* does it between siblings.
Extracting Components
Long class lists are the standard complaint, and it is fair once a string passes about a dozen utilities. The fix is extracting at the right layer, not abandoning utilities.
Framework components
const variants = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-200 text-gray-900 hover:bg-gray-300",
ghost: "bg-transparent text-gray-600 hover:bg-gray-100",
};
const sizes = {
sm: "text-sm px-3 py-1.5",
md: "text-base px-4 py-2",
lg: "text-lg px-6 py-3",
};
export function Button({ variant = "primary", size = "md", children, onClick }: ButtonProps) {
return (
<button onClick={onClick} className={cn("font-semibold rounded-lg transition-colors", variants[variant], sizes[size])}>
{children}
</button>
);
}tsxcn here is twMerge(clsx(...)), the standard pairing: clsx resolves conditionals, tailwind-merge resolves conflicts so a px-6 passed by a caller beats the component's own px-4 instead of losing to source order.
@apply, sparingly
.btn-primary {
@apply bg-blue-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-blue-700;
}cssReach for this only when you cannot put a class on the element: markup produced by a third-party library, or content rendered from Markdown. Using it as the default pattern reintroduces the naming problem and the dead-CSS problem that utilities removed.
For long-form content the typography plugin is the better answer, and it is exactly the case @apply is bad at:
@plugin "@tailwindcss/typography";css<article class="prose prose-lg mx-auto"></article>htmlWhat the CSS Actually Weighs
Tailwind's own claim is that only the classes you use are generated. Here is what that produced for this site, measured on the build that shipped this post:
| bytes | |
|---|---|
| Production CSS, raw | 83,291 |
| Production CSS, gzip -9 | 13,620 |
That is one stylesheet of 13.3 KB gzipped for the whole site: 27 routes, shadcn/ui primitives, a dark theme, tw-animate-css and the typography plugin, on Tailwind 4.2.2 built by Next.js 16.1.7 with Turbopack. Reproduce it with npm run build and gzip -9 -c on the emitted file in .next/static/chunks.
Two honest caveats. This is a small content site, and the number scales with the variety of utilities across your source, not with page count, so a large app with many bespoke layouts lands higher. And that figure includes our own component layer and the typography plugin, not a bare utility set. Measure your own build rather than quoting either this number or the ones in Tailwind's marketing copy.
The practical rules that keep it small:
- Never build class names by concatenation.
bg-${color}-500is invisible to the scanner and will not be generated. Write the full class and switch on it. - Do not reach for
safelistunless a class genuinely only exists at runtime.
What Utility-First Costs
Every argument for Tailwind above is real. These are the bills, and a post that skips them is an advert.
- Markup gets noisy. A component with responsive, hover, focus and dark variants carries thirty-odd classes on one line. It is genuinely harder to scan than
class="card", and no amount of familiarity fully removes that. - Diffs get worse. A styling change and a structural change land on the same line, so
git blameand review both lose resolution. Splitting classes across lines helps a little. - The design system is only as good as your discipline. Nothing stops
w-[347px]. Arbitrary values are an escape hatch that quietly becomes the norm, and once they are scattered you no longer have tokens, you have inline styles with extra syntax. - v4 raises the browser floor. It requires Safari 16.4+, Chrome 111+ and Firefox 128+, because the output uses
@propertyandcolor-mix(). If you support older browsers, v4 is not available to you at any price. - The migration is not only a rename. Beyond the codemod: the default border colour changed from
gray-200tocurrentColor, the default ring went from 3px blue to 1pxcurrentColor, and several utilities shifted a step in the size scale (shadowis nowshadow-sm,rounded-smis nowrounded-xs,outline-noneis nowoutline-hidden). These do not error. They just look slightly wrong everywhere until someone notices. - Your styling is coupled to a build step. No PostCSS, no styles. That is fine in a bundled app and a real obstacle in a plain HTML page or an email template.
Migrating From v3
npx @tailwindcss/upgradebashIt needs Node 20 or higher, and it handles the mechanical work: @tailwind directives to @import, the PostCSS plugin rename, deprecated utilities (bg-opacity-* to bg-black/50, flex-shrink-* to shrink-*, overflow-ellipsis to text-ellipsis) and the renamed size scale.
What it cannot do for you:
- Decide what the changed border and ring defaults should be. Run the diff visually and add explicit
border-gray-200where the old default was doing work. - Move your JavaScript config into CSS. A JS config still works, but v4 no longer auto-detects it, so you have to load it explicitly with
@config "../../tailwind.config.js", andcorePlugins,safelistandseparatorare no longer supported there at all. - Replace
darkMode: "class"with the@custom-variant darkline.
Do it on a branch, and look at the rendered pages rather than trusting a green build. We ran the tool against a real Astro site and wrote down everything it did and the two build errors it left, including the one where the CSS came out larger, not smaller.
Common Mistakes
Arbitrary values everywhere. w-[347px] and mt-[13px] defeat the point. Use the scale, and add a token when the scale genuinely does not fit.
Following v3 tutorials. The config file, darkMode: "class", the @tailwind directives, the container-queries plugin, the content array. All of it is stale, and none of it errors loudly.
@apply as the default. If your stylesheet is mostly @apply blocks, you are writing CSS with extra steps.
FAQ
What is the main difference between Tailwind v3 and v4?
Configuration moved from JavaScript into CSS. The theme is declared with @theme as custom properties, plugins are loaded with @plugin, and variants such as class-based dark mode are declared with @custom-variant. A JavaScript config still works but is no longer detected automatically; you load it with @config. The PostCSS plugin also moved to a separate @tailwindcss/postcss package, and the three @tailwind directives became one @import "tailwindcss".
How do I enable class-based dark mode in Tailwind v4?
Add @custom-variant dark (&:where(.dark, .dark *)); to your CSS after importing Tailwind. The v3 darkMode: "class" config key has no effect in v4, which is why toggles silently stop working after an upgrade.
How big is a Tailwind CSS bundle in production?
Only the utilities your source actually uses are generated, so it scales with the variety of styling in your codebase rather than with the number of pages. This site's entire stylesheet, including shadcn/ui components, a dark theme and the typography plugin, is 83,291 bytes raw and 13,620 bytes gzipped on Tailwind 4.2.2. A large application with many bespoke layouts will be larger. Measure your own build.
Do I still need the container queries plugin?
No. Container queries are part of core in Tailwind v4. Mark an element @container and use @sm, @md and similar variants on its children. The separate @tailwindcss/container-queries package is only needed on v3.
Which browsers does Tailwind v4 support?
Safari 16.4 and newer, Chrome 111 and newer, Firefox 128 and newer. The generated CSS relies on @property and color-mix(), so older browsers are not partially supported, they are unsupported. Projects that need to reach them should stay on v3.
Should I use Tailwind or CSS Modules?
They coexist without conflict, and most projects end up doing exactly that. Tailwind covers the great majority of styling; CSS Modules handle the cases that do not map onto utilities well, such as multi-step keyframe animations or deeply nested third-party markup.
Can I use @apply to keep my markup clean?
You can, and mostly you should not. It brings back class naming and dead CSS, the two problems utilities remove. Extract a framework component instead. Keep @apply for markup you do not control and for global prose styling, where there is no component to extract.
Sources
Checked 2026-08-20.
- Tailwind CSS upgrade guide - the
npx @tailwindcss/upgradetool and its Node 20 requirement, the renamed and removed utilities, the border and ring default changes, the PostCSS package rename,@confighandling of JavaScript configs, and the Safari 16.4 / Chrome 111 / Firefox 128 browser floor. - Dark mode - the
@custom-variant darkdeclaration that replacesdarkMode: "class". - Responsive design - breakpoint values, customising them through
@theme, and container queries in core. - Theme variables -
@theme, theinlineform, and resetting a namespace with--namespace-*: initial. - Bundle figures measured locally on this repository at Tailwind 4.2.2 and Next.js 16.1.7:
npm run build, thengzip -9 -con the emitted stylesheet in.next/static/chunks.