Taming Tailwind, part 2
In the first Taming Tailwind post, I documented how I’d inherited a Tailwind project that I eventually switched from a utility-first approach to a hybrid modern CSS + Tailwind architecture. I didn’t intend to write any more on the subject, but then they went an released a major rewrite with Tailwind 4. This is the story of that upgrade; the good, the bad, the WTFs, with even more taming.
Tailwind 4; now featuring CSS
The major change from v3 is a significantly more CSS-forward stance; CSS-based configuration, design tokens as custom properties, real cascade layers instead of the faux-layer system that made it impossible to use real cascade layers (I’m not bitter). No beating around the bush; this is an extremely positive change, and coupled with streamlined tooling, v4 is flatly better, and significantly so.
Here’s the entirety of the v4 configuration for the project:
@import 'tailwindcss' source("../../../app");
@source "../../../app/**/*.svg";
@source "../../../app/frontend/**/*.css";
@source "../../../app/{components,views}/**/*.html*";
@source "../../../config/initializers/*.rb";
@variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));
@theme {
--color-*: initial;
}
Sufficed to say, this is much more pleasant – and much less config – than the json v3 equivalent. Note the --color-*: initial;
line, we’ll get to that later.
Upgrading; ouch
Some libraries take great pains to avoid breaking changes, and/or maintain backwards compatibility. And then there’s Tailwind. Breaking changes were everywhere. Some were obviously due to under the hood changes, but the majority of it seems to have come from a “let’s revisit all of our decisions” place.
To their credit, there is a migration script, but as you can see from the releases, it’s been an ongoing work in progress. At the time that I made the switch, I’ll hazard a guess that the migration script took care of about half of the work, and I had to exhaustively QA every single route. Woof.
Not unique to Tailwind, but this is a prime example of why I don’t like using CSS libraries. They come with a tax, and the bill comes due at upgrade time.
Out with the theme() function, in with custom properties
Tailwind 3 included a theme()
function that used a dot notation to access various token values. You’d use it like this:
.button {
font-weight: theme(fontWeight.normal);
}
That’s toast in v4. Instead, most tokens are just regular old custom properties:
.button {
font-weight: var(--font-weight-normal);
}
So much better, particularly for the kind of approach I’m using here, where Tailwind is being used alongside regular CSS. It feels like one system now, which makes things easier to reason about.
Note that I said “most tokens”. V4 includes a few single purpose functions; one for spacing values, and another for manipulating opacity. Here’s how they work:
.button {
color: --alpha(var(--color-text) / 10%);
padding: --spacing(2);
}
These compile down to color-mix()
and calc()
respectively. You can also use the underlying var(--spacing)
custom property if you want the default value. Obviously you can use any number in these functions, so they don’t set you up for success consistency-wise. On the plus side, they’re syntactically identical to forthcoming CSS functions, which’ll make for an easy transition when they’re ready for prime time.
Color management; still bad
Aside from switching to custom properties, the Tailwind color story is unchanged from v3. Which is to say, it’s still one of the worst aspects of Tailwind. It starts with the assumption that you’re going to litter every component with dark:
variants. So right out of the gate, you’re signing up for utility bloat, and touching every component if you want to change colors. Now let’s say you want to take thinks a step further and introduce additional color themes. Here’s the Tailwind way, pulled straight from the docs:
@custom-variant theme-midnight (&:where([data-theme="midnight"] *));
Which would get used like:
<div class="bg-white dark:bg-black theme-midnight:bg-blue-800"></div>
That’s right, the answer is even more component-level utility bloat. Utter madness. I honestly struggle to think of a worse way to managing color with CSS. So I relieved Tailwind of the job, and implemented a system that I outlined in Chasing Color. First step is dropping all the Tailwind provided colors in the Tailwind config:
@theme {
--color-*: initial;
}
Then defining some new :root
colors:
:root {
/* color: core */
--color-white: oklch(100% 0 0);
--color-neutral: oklch(60% 0.04 276);
--color-accent: oklch(55% 0.23 276);
--color-success: oklch(69% 0.12 195);
--color-danger: oklch(60% 0.18 10);
--color-warning: oklch(69% 0.15 33);
--color-link: oklch(60% 0.18 256);
--color-highlight: oklch(79% 0.3 90);
/* color: steps */
--50: 98% calc(c/16) h;
--100: 95% calc(c/8) h;
--200: 93% calc(c/4) h;
--300: 85% calc(c/1.5) h;
--400: 75% c h;
--500: 55% c h;
--600: 50% c h;
--700: 35% calc(c/1.5) h;
--800: 25% calc(c/2) h;
--900: 21% calc(c/2) h;
--950: 19% calc(c/2) h;
/* color: presets */
--color-text: light-dark(
oklch(from var(--color-neutral) var(--900)),
oklch(from var(--color-neutral) var(--50))
);
--color-text-muted: light-dark(
oklch(from var(--color-neutral) var(--600)),
oklch(from var(--color-neutral) var(--400))
);
--color-text-subtle: light-dark(
oklch(from var(--color-neutral) var(--400)),
oklch(from var(--color-neutral) var(--600))
);
/* more presets... */
}
Those presets are used directly in CSS, and a set of non-Tailwind utilities applies select presets in the random instances where UI is composed of utilities (mostly non-customer facing admin views).
Want to change the accent color globally? Change one custom property. Want to add additional themes? Redefine a few custom properties. What’s more, this approach adds a significant amount of consistency, makes it so you don’t have to think about themes and color modes at the component level at all, and in practice shed a surprising amount of weight from the production CSS bundle.
Closing thoughts
V4 is the best version of Tailwind, and a vast improvement from v3. But the simple truth remains; utility-only CSS is a deeply flawed architectural approach, and using more modern CSS features doesn’t change that fact. Which makes mitigation – like with asbestos – the best approach.
For this project in particular, the worst offenders have been fully excised. The project is mostly component CSS now, with Tailwind relegated to build, token management, and exception management duty. Because of that, there’s not a strong reason to spend the time it’d take to full remove it. It just doesn’t make sense given all the other priorities we have. But as more CSS features become widely available, like functions and custom media queries, I’ll keep chipping away at it. If I write a third post in this series, it’ll be brief. “I got rid of Tailwind. The end.”