Tailwind 4, and the stylesheet we had already written

The framework was evaluated in 2020, 2022 and 2023, and rejected each time for reasons that had become habitual rather than examined. The January release moves configuration out of JavaScript and into CSS, which is a large enough change to the thing being evaluated that the previous answers do not carry.

The symptom

the stylesheet, accounted for honestly:

  2,100 lines, of which
    412  layout primitives — grid, stack, cluster,
         sidebar. written once, changed twice in four
         years.
    880  components. changed constantly.
    290  tokens, generated from tokens.json.
    518  utilities — spacing, colour, display,
         typography helpers.

  people who can change it confidently: 1
  people who could change a framework's classes
    confidently on day one: 3 of 3

The fluency number is the finding. A stylesheet that one person can change is a bottleneck regardless of its quality, and the utility layer — the quarter that a framework replaces — is where most changes land.

Why it happens

A hand-written design system is better tailored and worse shared. Every decision in it is justified and none of them are discoverable, so the cost of a change is proportional to how much of the file somebody has read.

The fix

What actually changed in 4.0

  tailwind.config.js  →  @theme in a stylesheet
  content globs       →  automatic source detection
  the PostCSS chain   →  one plugin, or a Vite plugin
  @tailwind base      →  @import "tailwindcss"

the engine rewrite is the headline and is irrelevant to
a build that already takes 400ms.

the configuration move is the change that matters: the
settings are now readable by anybody who reads CSS,
rather than by anybody who reads a JavaScript object
with a documented shape.

The token overlap

/* theirs */
@theme {
  --color-brand: #1f6feb;
  --spacing-m: 1.5rem;
  --font-sans: Inter, sans-serif;
}

/* ours, generated from tokens.json since 2022 */
:root {
  --wp--preset--color--brand: #1f6feb;
  --wp--preset--spacing--m: 1.5rem;
}

These are the same mechanism with different naming conventions, which means the token half of a migration is a rename in the generator rather than a rewrite. That removes the strongest previous objection — that adopting a framework would fork the design system — and it was the finding that made the rest of the evaluation worth doing.

One component, converted honestly

the product card, both ways:

  hand-written    41 lines of CSS, 6 class names in
                  the markup
  framework       0 lines of CSS, 31 utility classes
                  across 8 elements

output after purging, for the whole site:

  ours            18.4 KB gzipped
  theirs          11.2 KB gzipped
  both (during a
    migration)    26.1 KB

and the markup diff is the part to look at rather than
the numbers.

Seven kilobytes is a real saving and is not a reason to do anything. The markup is where the trade actually is: thirty-one utility classes on eight elements is readable to somebody who knows the vocabulary and is noise to somebody who does not, and the same is true of six semantic class names in the other direction.

What it would replace, and what it would not

  utilities        518 lines. replaced entirely, and
                   this is the part where a framework
                   is straightforwardly better.
  tokens           290 lines. renamed, not replaced.
  components       880 lines. replaced by markup,
                   which moves the complexity rather
                   than removing it.
  layout
  primitives       412 lines. NOT replaced. grid-
                   template-areas, container queries
                   and subgrid are not utility-shaped.

so: a quarter is a clear win, a quarter is a rename,
and half is a relocation or an exception.

The decision

not adopted, and the reason is not technical:

  a migration is 880 lines of components becoming
  markup across 140 templates, plus 412 lines that
  stay and now sit alongside a different paradigm.

  the fluency problem it solves is real. it would be
  solved more cheaply by two afternoons of somebody
  else reading the stylesheet.

  and the utility layer — the clear win — can be
  adopted on its own without the framework, which is
  what we did: 518 lines regenerated from tokens,
  named to match the framework's conventions.

Taking the idea and not the dependency is the outcome, and it is the one that felt like a cop-out until the naming convention made the utilities discoverable to somebody who knows the framework. That is most of the fluency benefit for none of the migration, and it is available precisely because the token layers are the same idea.

Verifying it worked

$ npx vite build && ls -la dist/assets/*.css | awk '{print $5}'
18104              # unchanged

$ git log --oneline --since=2025-02-01 -- assets/css/ | wc -l
22
$ git log --format='%an' --since=2025-02-01 -- assets/css/ | sort -u | wc -l
3                  # was 1

# the prototype branch
$ git branch -d prototype/tailwind-4
  Deleted branch prototype/tailwind-4

Three people committing to the stylesheet where there had been one is the outcome that the whole exercise was about, and it came from renaming the utilities rather than from adopting anything. Deleting the prototype branch is the other half — a prototype whose conclusions are recorded is finished, and one that survives becomes an argument in two years.

What this costs

A decision that will be revisited, because the reasons are about this team at this size and all of those change. The measurements are in the decision record specifically so the fifth evaluation is a read rather than a fortnight, which is the only durable output of an exercise like this.

Naming our utilities after somebody else’s convention is also a mild form of the coupling we declined — if their vocabulary changes in a major release, ours either diverges or follows something we are not using. That is a small cost and it is a real one, and it was accepted with the same shrug that this paragraph deserves.