NoWaterProgramming

Tailwind CSS v4 in Practice: CSS-First Config, What It Costs, and a Real Bundle Size

Tailwind v4 moved configuration out of JavaScript and into CSS. What that changes for theming and dark mode, what the upgrade breaks, the browsers it drops, and the measured CSS size of a real site.

12 min read
Share:

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>
html

Each 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 postcss
bash
// postcss.config.mjs
const config = {
  plugins: {
    "@tailwindcss/postcss": {},
  },
};
 
export default config;
js
/* globals.css */
@import "tailwindcss";
css

Two 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);
}
css

Adding --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;
}
css

This 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);
}
css

Note @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 *));
css

This 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
PrefixMin width
sm640px
md768px
lg1024px
xl1280px
2xl1536px

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>
html

This 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>
html

group-* 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>
  );
}
tsx

cn 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;
}
css

Reach 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>
html

What 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, raw83,291
Production CSS, gzip -913,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}-500 is invisible to the scanner and will not be generated. Write the full class and switch on it.
  • Do not reach for safelist unless 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 blame and 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 @property and color-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-200 to currentColor, the default ring went from 3px blue to 1px currentColor, and several utilities shifted a step in the size scale (shadow is now shadow-sm, rounded-sm is now rounded-xs, outline-none is now outline-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/upgrade
bash

It 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-200 where 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", and corePlugins, safelist and separator are no longer supported there at all.
  • Replace darkMode: "class" with the @custom-variant dark line.

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/upgrade tool and its Node 20 requirement, the renamed and removed utilities, the border and ring default changes, the PostCSS package rename, @config handling of JavaScript configs, and the Safari 16.4 / Chrome 111 / Firefox 128 browser floor.
  • Dark mode - the @custom-variant dark declaration that replaces darkMode: "class".
  • Responsive design - breakpoint values, customising them through @theme, and container queries in core.
  • Theme variables - @theme, the inline form, 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, then gzip -9 -c on the emitted stylesheet in .next/static/chunks.

Related Posts

12 min read
We ran npx @tailwindcss/upgrade on a production Astro site, moving Tailwind 3.4.19 to 4.3.3. The tool rewrote the PostCSS config, translated and deleted tailwind.config.mjs, and renamed classes across 31 files, all correctly. It also left two build-breaking errors specific to Astro, and the main stylesheet came out 28% larger gzipped.
By NoWaterProgramming Team
14 min read
How a pixel-comparison overlay actually works in the browser: why a wrapper div breaks mix-blend-mode, click-through without pointer-events juggling, keeping scroll on its fast path, and hardening per-origin persisted state.
By NoWaterProgramming Team