← Lab

12 Aug 2026 · 6 min

Tailwind over CSS Modules, quirks and all

I have shipped vanilla-extract, Sass and CSS Modules, and still reach for Tailwind first. Not because it is elegant, but because of what it prevents.

01 — The note

I reach for Tailwind first on almost everything now. That is not an aesthetic judgement. The class attributes get long and I am not going to pretend otherwise. It is a judgement about which failure mode I would rather live with.

The alternatives are good, which is the annoying part

This would be an easier argument if the other options were bad.

CSS Modules solved the problem they set out to solve. Scoped class names, real CSS, no runtime, nothing to learn beyond a naming convention. Vanilla-extract went further and gave the same thing a type system, so a token is a value your editor knows about rather than a string you hope still exists. I have shipped vanilla-extract in production and played with it locally across several projects since, and I liked it every time. Writing a component’s styles in a .css.ts file next to it, with autocomplete on your own scale, is a genuinely nice way to work.

So the case for Tailwind is not that the alternatives are broken. It is that they solve a different problem than the one I keep running into.

The problem is drift, and scoping does not fix it

Scoped styles stop your button bleeding into someone else’s button. They do nothing about the same value being written down in nine places.

I found this in my own codebase, on this site, which has one author and a written design spec. When I audited it:

  • rounded-[10px] appeared eleven times, while the spec named that exact value --ft-radius-card
  • text-[15px] appeared fifteen times, and the spec named it too
  • twenty-six elements carried an inline letter-spacing, bypassing the system entirely

One person. A spec sitting in the repo. Still nine places to change if the card radius moves.

CSS Modules would not have caught any of it. Six modules each declaring border-radius: 10px are six correct-looking files. Nothing is out of scope, nothing conflicts, nothing warns you. The drift is invisible precisely because the scoping worked. On a team, with a few people who each own a couple of components, that is how a design system quietly becomes a suggestion.

Utilities do not fix this by being clever. They fix it by making the wrong thing visible. You cannot casually write border-radius: 10.5px in a utility codebase. You have to type rounded-[10.5px], in square brackets, in the markup, where it looks exactly as much like an exception as it is.

The quirks are real, and I hit five of them on this page

I am not going to sell this as frictionless. Recent, specific, all from building this site on Tailwind v4:

Unknown utilities fail silently. Typo a class and you get no CSS, no error, and a green build. There is no warning because there is nothing to warn about; the utility simply was never generated. The only way I could be sure was to extract all 267 class tokens from source and diff them against the selectors in the compiled stylesheet.

v4 moved translate, rotate and scale onto the individual CSS properties. They no longer go through the composite transform. That means transition-transform stops covering a translate-y utility, and, worse, a prefers-reduced-motion block written as transform: none silently stops resetting anything. Two hover lifts on this site kept moving for users who had asked them not to. Reading the CSS would not have told me. I found it by hovering each element in a headless browser and sampling the computed offset.

The fix for that had its own trap. Adding translate, rotate and scale: none next to transform: none looks right and gets folded by the minifier into transform: translate(0) rotate(0) scale(1), which sets only the composite property. The longhands go unreset. The bug comes back, and it is invisible in the source you wrote.

Named text utilities carry a line-height. --text-3xl is exactly 30px, so text-[30px] to text-3xl looks like a free cleanup. It is not, because the named one also sets a line-height and the arbitrary one does not.

Arbitrary values become the default if you let them. The escape hatch is frictionless enough that seventy of mine had exact scale equivalents I had simply not bothered to use.

None of that is a small print footnote. That is a real afternoon.

Why I stay anyway

Because v4 made the token layer the actual source of truth, and that is the part that pays.

Tokens live in @theme. Utilities are generated from them. The design spec and the stylesheet stop being two documents that agree by convention. When I swapped this site’s primary accent from green to the blue in my resume, the change was one hex value and a rename, applied across forty-three references in a single pass. The page that documents the palette updated itself, because it renders from the same tokens rather than from a copy of them.

That is the trade. I accept a noisier class attribute and a handful of sharp edges, and I get a codebase where changing a design decision is one edit instead of nine, and where the drift shows up in the markup instead of hiding in a module.

It also is not all-or-nothing, which took me too long to accept. This site has thirty-three component classes for the devices that repeat, the button, the eyebrow, the section strip. Utilities handle everything else. Purity in either direction is the actual mistake. Utilities for the one-offs, a named class the moment something has a name.

Where I would still pick something else

Mostly it comes down to what the team is comfortable with. That is a less satisfying answer than a technical one, and it is the honest one. Styling is a decision everybody on the team touches every day, so the tool that wins is the one people will actually use consistently, not the one that wins the argument in the channel.

Given room to try something, I will take it. shadcn/ui sits on Tailwind and hands you components you own outright instead of a dependency you configure around, which is the right trade for most product work. The one I have been watching is Astryx, Meta’s React component library, which uses StyleX underneath. Accessible and themeable out of the box, with the styling compiled rather than resolved at runtime. If a team had the appetite, I would want to build something real with it.

Where there is no appetite for something new, vanilla-extract or one of its neighbours does the job well and I would not push back.

And if none of that lands, CSS Modules. Not because it is exciting. Because it is safe, everyone already understands it, and shipping matters more than winning the tooling debate.

For an application with a design system behind it, though, which is most of what I build, and where the team is happy with it, I would start on Tailwind again tomorrow. Quirks included. I know what they are now, and I would rather debug a silent utility than find the same hex written down in nine files a year later.