The Case for Tailwind CSS
The Semantic Past
Remember when we were told to make CSS classes “semantic”?
<div class="card"></div>
<div class="avatar"></div>
.card {
background: black;
color: white;
}
.avatar {
background: black;
border-radius: 100%;
}
I’ve written a ton of code like this. Most likely, you have too. And yet, even with strict naming (BEM) and scoping (CSS modules), I’ve never seen it age gracefully. As the code grows, it gets harder to work with. Deleting old CSS is especially perilous.
Why does this happen? Maybe “best practice” is actually bad in the long run?
Let’s dig into how semantic CSS works. Each class is an interface that links markup to corresponding styles. When taken as a whole, the classes form an API layer between markup and styles:
// Markup // API // Styles
<div> .card background: black
color: white
<div> .avatar background: black
border-radius: 100%
What are some characteristics of this API?
- It’s hard to guess what a class does without reading its implementation.
- Each class can have multiple effects.
- Different classes can have the same effect.
All hallmarks of a bad abstraction.
Now let’s continue implementing the UI. To represent new objects, we need to create new classes. This causes the API surface to grow unbounded:
// Markup // API // Styles
<div> .card__label--sm font-size: 90%
<div> .card__label--md font-size: 100%
<div> .card__label--lg font-size: 110%
<div> .avatar__name--sm font-size: 90%
<div> .avatar__name--md font-size: 100%
<div> .avatar__name--lg font-size: 110%
Etc...
In a few weeks, we’ll find ourselves flipping back and forth between HTML and CSS files, trying to understand the API before we can even work on the code.
The Functional Future
Tailwind—and in general, functional CSS libraries—provide a finite API.
Each class is an abstraction over a UI primitive, not a UI object. They represent things like color (.bg-black
), size (.w-8
), whitespace (.m-2
), and typography (.text-sm
).
Instead of creating new classes, we compose them. The number of classes is finite, but the combinations are essentially infinite. This gives us the expressive power to implement any UI:
function Card() {
return <div className="bg-black text-white"></div>;
}
function Avatar() {
return <div className="bg-black rounded-full"></div>;
}
What are some characteristics of this API?
-
Code is easy to add and remove. Since each class has a single responsibility, we can compose them without side effects. When a component is deleted, its styles get deleted too.
-
Old/foreign code is easy to understand. Since Tailwind’s API is stable, we can learn it. The documentation is great, and the community has thousands of engineers speaking the same vocabulary.
All hallmarks of maintainable code.
In Practice
Like many others, I needed time to unlearn old “best practices.” It was hard to believe that we should replace “clean” semantic classes with “illegible” markup.
But, once it clicked, Tailwind completely changed how I write and maintain CSS. I now consider semantic CSS harmful and avoid it whenever I can.
Some Trade-offs
As with any library, there are trade-offs.
-
There is a risk of bloat. Ideally, Tailwind should ship the minimal set of primitives that can be composed to express all UIs, which is difficult for the authors to maintain over time. (Fortunately, Tailwind tree-shakes.)
-
It’s impossible to replicate the full expressivity of CSS. (Tailwind provides an elegant escape hatch to raw CSS.)
-
Evolving a design system can be a large-scale task that involves editing all call-sites of a class—e.g. all instances of
blue-500
must now begreen-500
. (Although painful, imagine doing this in a semantic CSS codebase.) -
Small, bespoke websites that lack a design system might find Tailwind too restrictive.