The Tailwind v4 upgrade that changed nothing, on purpose | WaretaGarasuSkip to main content
WaretaGarasu
Language
Theme
Glass
All writingNote · CSS

The Tailwind v4 upgrade that changed nothing, on purpose

Moving from Tailwind v3 to v4 was supposed to be an invisible config change. Cascade layers made it briefly catastrophic, and then revealed that a third of my color classes had never rendered a single rule.

Tailwind CSSCSS Cascade LayersMigrationDebugging

Why upgrade at all

There was no feature I was chasing. Tailwind v4 moves configuration out of a JavaScript file and into CSS: the theme lives in an @theme block, colors and spacing become custom properties, and tailwind.config.ts simply stops existing. For a site whose whole design system already lived in CSS custom properties, that felt less like a migration and more like the config finally moving to where the rest of the styling already was.

So the bar was simple, and high: nothing on screen should move by a single pixel. An infrastructure change you can see is an infrastructure change that went wrong. I expected a quiet afternoon. I got a site with no spacing.

The cascade-layer trap

The first build came up with every margin and gap gone. Stats boxes stacked flush, the layout shoved left, padding evaporated site-wide. Nothing in my CSS had changed. What changed is that v4 wraps Tailwind in CSS cascade layers, and the import line declares their order before anything else runs.

/* v3 was a flat, unlayered cascade: specificity and source order won. */

/* v4: @import declares the layer order up front. */
@import 'tailwindcss'; /* layer order: theme, base, components, utilities */

/* The trap: residual.css opens with a universal reset (0,0,0 specificity).
   Left unlayered it sits ABOVE Tailwind's utilities by layer ORDER, so
   margin: 0 beats .p-6 / .mx-auto and every spacing utility collapses. */

/* The fix: put project CSS in the SAME layer as Tailwind's utilities, so
   specificity decides again, exactly the way v3 behaved. */
@import './styles/residual.css' layer(utilities);

Cascade layers have one rule that overrides instinct: layer order beats specificity, always. A declaration in a later layer wins over any selector in an earlier one, no matter how specific. My residual stylesheet opens with a universal reset, margin: 0 with the lowest specificity there is, but because it sat unlayered it ranked above Tailwind's whole utilities layer. A zero-specificity reset was quietly beating .p-6 and .mx-auto on every element. The fix was to import the project CSS into the same utilities layer as Tailwind. Once they share a layer, specificity decides again, exactly as it did in v3's flat cascade, and the spacing came back.

The classes that never existed

With layout restored, I started auditing colors and found something stranger. Classes the components leaned on, bg-card, text-muted, bg-soft, text-strong, produced no CSS at all. Not wrong CSS. None. I checked the deployed stylesheet to be sure, and they were absent there too. They had never worked.

// v3 keyed colors by the MIDDLE segment of the utility:
//   colors: { 'bg-card': 'var(--bg-card)', 'text-muted': 'var(--text-muted)' }
// Tailwind builds the class as {prefix}-{key}, so the key 'bg-card'
// generated .bg-bg-card, never .bg-card. The class the JSX used was dead.

<div className="bg-card text-muted" />  // compiled to nothing, for a long time

// v4 @theme, names preserved on purpose so the upgrade stayed pixel-identical:
@theme {
  --color-bg-card: var(--bg-card);     // still only emits .bg-bg-card
  --color-text-muted: var(--text-muted);
}

The cause was a v3 config quirk carried in from the very first commit. Tailwind builds a utility as prefix plus key, so a color keyed 'bg-card' generated .bg-bg-card, not .bg-card. The class the JSX actually wrote was a no-op, and had been for the entire life of the site. It looked fine only because the elements inherited a sensible color anyway. The honest move during a no-diff migration was to keep the broken names exactly as they were, so the v4 build stayed pixel-identical to what was already in production. Fixing them is a real change with real visual consequences, and it does not belong in an upgrade whose entire promise is that nothing changes.

Removing 253 things that did nothing

Once the upgrade shipped, the dead classes were worth deleting on their own terms. A small codemod stripped 253 of them from 29 files, with lookbehind guards so it never touched the live arbitrary-value classes that look similar. The proof that it was safe was the most satisfying diff of the whole project: rebuild, and the generated CSS came out byte-identical. Removing something that emits nothing cannot change the output, and the build agreed.

The dead @theme keys went too, and that is where I nearly lost an afternoon. Removing them dropped a handful of rules from my local build, which looked like a regression until I checked the deployed CSS and saw those rules were never there. My local build was emitting phantom utilities, scanned out of a stale file in the working tree that a clean CI checkout never sees. Harmless, unused, invisible to the actual site, and a perfect way to scare yourself for twenty minutes.

Trust the deployed CSS, not the local build

The entire migration's correctness came down to one comparison: the stylesheet the live site serves, before and against after. The local rebuild lied twice, once with collapsed spacing from a layer-order quirk and once with phantom rules from a stale scan. The deployed CSS never did. When the goal is to change nothing, you need an oracle that actually reflects production, and the thing your visitors download is the only one that qualifies.