Sep 19, 2023

Taming Tailwind

I wasn’t a Tailwind fan. Between the heavy-handed approach, the needlessly antagonistic marketing, and a community prone to dog-piling valid criticism, I didn’t see much to be a fan of. But life comes at you fast, and I found myself starting a job at a company that was wrapping up a migration to – you guessed it – Tailwind.

So I set my hangups and pre-conceived notions aside, and did things the Tailwind way. For a solid year, I used it almost daily to design and build UI in a production Rails app. I know Tailwind, well and truly. Still not a fan.

Don’t get me wrong, there absolutely are bright spots. The structure and syntax of bread-and-butter utilities like margin, padding, and flex are clear, concise, and easy to work with, the color opacity system is emulation-worthy, and the documentation is excellent (praise be the type-CSS-to-get-Tailwind-CSS box).

Those bright spots just aren’t bright enough to distract from a weakly-held prior turned full-blown conviction; utility-only CSS doesn’t work.

The reality of utility CSS is that like 10% of properties – margin, padding, type sizing, color, showing / hiding things in different contexts – are doing 90% of the work. What that means in practice; a component CSS architecture with a narrow set of utilities to cover that 10% delivers almost all of the value that utility CSS can possibly deliver.

And the value is real. Utilities keep CSS small, components flexible, and architecture simple. But in a very cautionary tale kind of way, Tailwind proves that there really can be too much of a good thing. In order to grab that last 10% of value that utility CSS can potentially deliver, Tailwind has to do three things:

  1. Create utilities to cover all CSS properties and their infinite permutations.
  2. Create new, stripped down mechanisms to replace CSS behavior that’s too complex to be modeled as utilities.
  3. Create escape hatches to vanilla CSS, because #1 is impossible and #2 means there are things CSS can do that Tailwind can’t.

The end result is a framework that makes the easy things easier, the hard things harder, and invents problems where none need exist.

Anything with multiple states (read; anything interactive) is a soup of unstructured, repetitious state prefixes. Support dark mode? Get used to typing dark:, because you’re going to write it in front every color declaration in every part of every component.

Paradigm-shifting custom properties? Nope. Calc()? Only in arbitrary values, which really is equivalent to writing inline styles. Grid? Nerfed. Complex relationship selectors? Sorry, you get the pain stick group system instead.

It was this last one that broke me. I was working on some goal UI updates to goals in our app, which uses <details> elements that can nest into moderately complex parent/child structures. After an hour of struggling to get custom open/closed markers to toggle in accordance with their containing parent – a basic direct child selector – I was struck by a revelation.

I could just write CSS.

There’s no award for writing “pure Tailwind”. There are no Tailwind Cops that will come to your house and arrest you for holding Tailwind the way its authors didn’t intend. It’s open source, babe. And luckily, door #3 – the escape hatch – is surprisingly pleasant.

How to make CSS and Tailwind work together

The gist; use Tailwind’s theme function to map values from Tailwind to component-level custom properties. Here’s a simple, contrived example:

.button {
  --padding theme(padding.3);
  --color: theme(colors.gray.900);
  --background-color: theme(colors.white);

  [data-theme="dark"] & {
    --color: theme(colors.white);
    --background-color: theme(colors.gray.800 / 30%);

  padding: var(--padding);
  color: var(--color);
  background-color: var(--background-color);

In essence, you’re reducing Tailwind’s job down to “token manager”, and unlocking the full power of modern CSS by doing so. It means you can use Tailwind utilities for the simple stuff, component CSS for the more complex stuff, and have it all work off of the same set of values. It reduces Tailwind lock-in too. Your spiciest CSS starts 99% migrated; point at :root properties instead of Tailwind values and call it a day.

Values you type within the theme function tab complete, which coupled with the more intuitive naming structure, makes for a downright pleasant authoring experience. I wish my root custom props could auto-complete like that.

“What about @apply?” you might be asking. @apply manages to give you all the cons of component CSS and and all the cons of Tailwind in one package. Even the author doesn’t want you to use @apply.

Media queries

If you want to use Tailwind’s breakpoints in CSS, there’s a function for that too. It goes a little something like this:

.button {
  --font-size: theme(;

   @media screen(lg) {
    --font-size: theme(;

  font-size: var(--font-size);


I’ve spent a decent amount of time thinking about and implementing approaches for making multi-theme websites as simple as single color websites (see: theme picker on this very website). Initial values defined as custom properties are key, lest you consign yourself to the aforementioned type-dark-before-everything dungeon.

Tailwind doesn’t work that way out of the box, but it’s straightforward to make it so. Start by adding a root css file, and defining custom properties for your colors. You want to make sure that you only provide the raw values here; opacity modifiers won’t work otherwise.

:root {
  --color-asphalt-50: 230deg 10% 98%;
  --color-asphalt-100: 230deg 10% 96%;
  /* more colors... */

Next, customize your Tailwind theme colors, and map your custom properties to new Tailwind colors.

module.exports = {
  theme: {
    colors: {
      neutral: {
        50: "hsl(var(--color-asphalt-50) / <alpha-value>)",
        100: "hsl(var(--color-asphalt-100) / <alpha-value>)",

You can work with colors using the normal Tailwind color syntax, and you can do “cool color stuff” with custom props in your component CSS. Win-win.

Read more notes