Tailwind is a Leaky Abstraction


I have to admit: as I’ve watched Tailwind enthusiastically adopted by more and more of the frontend community, I’ve remained skeptical. But, having never used it, I decided to keep quiet until I had an informed opinion.

Well, I’ve spent the past few months at work learning Tailwind with an open mind. I can now confidently say that I do, in fact, dislike Tailwind, and I wouldn’t use it for any new projects.

Tailwind is commonly described as “utility classes”, but that’s a bit of an understatement. It’s essentially a small language you write in the class attributes of your HTML that compiles to a combination of CSS rules and selectors — an abstraction over CSS. But all abstractions leak The Law of Leaky Abstractions There’s a key piece of magic in the engineering of the Internet which you rely on every single day. It happens in the TCP protocol, one of the fundamental building blocks of the Internet. TCP… www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/ , and Tailwind is very leaky.

Let’s take transforms. I was trying to build an animation in which an element rotates in 3D space, so that it appears to come toward the viewer. To achieve that, I needed to rotate the element around the X axis. With CSS, this is easy: set a perspective perspective() - CSS: Cascading Style Sheets | MDN The perspective() CSS function defines a transformation that sets the distance between the user and the z=0 plane, the perspective from which the viewer would be if the 2-dimensional interface were 3-dimensional. Its result is a <transform-function> data type. developer.mozilla.org/en-US/docs/Web/CSS/transform-function/perspective on the parent element, and then set transform: rotateX rotateX() - CSS: Cascading Style Sheets | MDN The rotateX() CSS function defines a transformation that rotates an element around the abscissa (horizontal axis) without deforming it. Its result is a <transform-function> data type. developer.mozilla.org/en-US/docs/Web/CSS/transform-function/rotateX on the element you want to animate:

.parent {
  perspective: 1000px;
}

.animate {
  transform: rotateX(45deg);
}

Unfortunately, Tailwind doesn’t support the perspective property. Implementing any sort of 3D design requires breaking out of Tailwind.

Not that it would have mattered, though. Tailwind only supports the unadorned rotate, which uses the Z axis. If you want to rotate along any other axis, you’re out of luck. A discussion has been ongoing for almost two years Any progress of supporting `rotateX` or `rotateY`? · tailwindlabs/tailwindcss · Discussion #3521 My project requires an effect using rotateX, I searched and found this PR Add composable transform utilities awesome but without supporting of rotateX. Is there any plan or someting for this? If no... github.com/tailwindlabs/tailwindcss/discussions/3521 , with the official recommendation being to just write a custom JavaScript plugin to implement it.

You might protest that this is a niche issue (that’s what the Tailwind team says in the discussion). But there are issues with utilities that you’re likely to encounter in regular usage, too.

Even though it’s one of the main jobs of CSS, spacing things out has always been kind of a pain. Maybe that’s why Tailwind introduced the Space Between Space Between - Tailwind CSS Utilities for controlling the space between child elements. tailwindcss.com/docs/space utilities. Just throw space-x-2 or some similar class on the parent, and the children will magically work themselves out!

Of course, it’s not magic. What it actually does is use child and sibling selectors to set margins on the child elements. The aforelinked page shows the generated CSS:

.space-x-2 > * + * {
  margin-left: 0.5rem;
}

That applies a margin-left to every child after the first of an element with the class space-x-2. The relevant consequence is that now, attempting to set a left margin using a selector with lower specificity — say, the selector generated by a Tailwind margin utility — will fail.

I ran into this issue when I was doing exactly that: trying to use a margin utility on an element whose parent already had one of these space utilities applied to it. It took me a while to realize why every margin I set seemed to get ignored. These features are mutually exclusive.

In the meantime, CSS has gotten much better at spacing! You can accomplish the exact same thing without Tailwind by using two properties:

display: flex;
column-gap: 0.5rem;

Originally, I was going to write about how Tailwind doesn’t support attribute selectors. But a few days before I started this post, they came out with a huge update that added them Tailwind CSS v3.2: Dynamic breakpoints, multi-config, and container queries, oh my! - Tailwind CSS ...and nested group support, `aria-*` variants, `data-*` variants, `@supports` support, and more. tailwindcss.com/blog/tailwindcss-v3-2 . It’s a big improvement, but it also includes some of the leakiest parts of the abstraction yet. I’m referring in particular to the “arbitrary variants” feature Handling Hover, Focus, and Other States - Tailwind CSS Using utilities to style elements on hover, focus, and more. tailwindcss.com/docs/hover-focus-and-other-states#using-arbitrary-variants that allows you to include CSS selectors in your Tailwind classes.

Let’s look at some of the selectors here:

<div class="[&_p]:mt-4">
  <!-- ... -->
</div>

The & sigil lets you control nesting, similar to Sass and (soon) plain CSS CSS Nesting Module drafts.csswg.org/css-nesting/ . In CSS, the selector would be & p. Tailwind requires underscores instead of spaces, though, because a space would split the class name in two.

Here’s something a little more complex:

<ul role="list">
  {#each items as item}
  <li class="lg:[&:nth-child(3)]:hover:underline">{item}</li>
  {/each}
</ul>

To understand this class, you need to know how nth-child works :nth-child() - CSS: Cascading Style Sheets | MDN The :nth-child() CSS pseudo-class matches elements based on their position among a group of siblings. developer.mozilla.org/en-US/docs/Web/CSS/:nth-child . That’s an abstraction leak. But worse than that, it highlights a key shortcoming of Tailwind. In CSS, to set multiple properties using the same selector, you can write it once and group all the properties together. In Tailwind, there’s no choice but to write the full query again for every property:

<ul role="list">
  {#each items as item}
  <li
    class="lg:[&:nth-child(3)]:hover:underline lg:[&:nth-child(3)]:hover:font-bold lg:[&:nth-child(3)]:hover:text-blue-600 lg:[&:nth-child(3)]:hover:opacity-100"
  >
    {item}
  </li>
  {/each}
</ul>

Notice the long horizontal scrollbar. This isn’t just an abstraction that leaks some of the underlying details. It’s an abstraction that actively makes the experience worse.

Meanwhile, here’s the CSS that would be needed to accomplish the same thing. It’s still not simple. But it is scannable, and it doesn’t repeat the entire selector four different times.

@media (min-width: 1024px) {
  li:nth-child(3):hover {
    text-decoration: underline;
    font-weight: bold;
    color: var(--blue-600);
    opacity: 1;
  }
}

I realize this is a bit of a catch-22. Before, I was criticizing Tailwind for hiding CSS internals; now I’m criticizing it for exposing them. But that’s kind of the point. Tailwind is a layer on top of CSS, but it doesn’t actually hide any complexity in the layer below. You still need to know CSS.

This might be unfair to Tailwind. To my knowledge, the team has never promoted it as a CSS replacement. At its core, it really is just a set of class names that apply styles. But even after working with it for months, there’s still a mental translation layer between “Tailwind CSS” and “real CSS”.

These issues don’t mean Tailwind is bad. They’re just some of the tradeoffs you inevitably encounter when using a tool.

Maybe you really want to avoid coming up with names. Maybe you want to see your styles inside your markup, or use a ready-made set of design tokens. Tailwind will let you do those things. But the price you pay is that you need to know exactly how this tool interacts with CSS.

To me, that trade is a dealbreaker. It increases the number of tools I use without really giving me anything in return. It’s so leaky that I have to constantly think about the thing it’s supposed to abstract away. Yes, it’s nice to have my styles in the same file as the rest of my component code. But that convenience is heavily outweighed by the overhead of actually using Tailwind.