Tailwind Utility Classes as an antipattern
Recently I have been involved in the new architecture definition for a future-proof Frontend application. The company has had the same technology for more than 10 years in a monolith repository, and there is a clear need to break down that monolith to make it more maintainable and switch to a modern stack.
The main goals of the new architecture are:
- Scalability. It has to scale in terms of features and team size without degrading the quality.
- Separation of concerns. The code needs to be separated into several independent sections, maintained by different teams, and connected to each other through clear contracts.
- Future proof. In the future, there will be a need to refactor some parts of the solution, as the industry evolves and new technologies or frameworks appear. The system should handle a change in one of its packages without compromising the others, as long as the contracts between the parties are respected.
- Room for several websites/products. The solution should be suitable for more than one product, and all of them under the same conditions and best practices.
- Room for mobile applications. The current native applications could eventually be refactored to Javascript technologies and reuse the web libraries and patterns.
After an initial attempt to picture the system, several shared software layers were defined: remote data access, state management, data transformation, UI library, server rendering… Those layers would contain generic code, and would be used to build the products.
This post will cover one of the decisions related to the UI library and specifically why we chose not to use Tailwind utility classes, as we think it is an antipattern and does not meet the requirements of a scalable and future-proof architecture.
The industry is constantly under rapid changes
The web technology industry changes very quickly. Every few years a new framework or tool gets all the attention and immediately there is a massive move towards it. The way we work today is very different from what we did 10 years ago, and it had already changed completely from what we did 20 years ago.
Chances are that some of the tools we use today will be considered legacy technology in a few years.
Decoupling as a future-proof approach
The idea behind a future-proof technical solution is that any of its parties can evolve or be completely refactored without breaking the system, as long as it respects the internal contracts. To accomplish that, decoupling seems like a reasonable approach.
In that sense, every technology decision has to be considered also in terms of how costly it would be to replace or refactor it.
Tailwind couples code at the lowest level
Tailwind utility classes produce coupled markup and styles at the lowest level possible. Period.
It’s a fact that HTML and CSS always need to be connected through classes (indeed selectors). Those class names can be defined after visual or semantic patterns but they are abstractions, and as such, they can be at different levels.
Each Tailwind utility class encapsulates a single CSS property one-to-one, which is the lowest level of abstraction possible. If we think of classes as tools, a Tailwind utility class is the simplest type. In the vast majority of the cases, an element will need more than a single class.
Personally, I can understand a few advantages of low-level utility classes: strong conventions, consistency, no setup needed, easy access to design tokens, no need for naming conventions, available documentation… but, apart from the cons inherent to every decision, all of the advantages fade out in an environment that favors long term and performance over immediacy.
I want to focus on the Tailwind utility classes approach, it is not my intention to criticize Tailwind as a technical solution. That is why I have ruled out from this article specific Tailwind issues such as the compatibility problems with other PostCSS plugins, the lack of mixins with parameters and content, functions, or loops, the known per-component CSS issue (and its dodgy solution), the incompatibility with the Design tokens W3C standard and other standard CSS solutions, or the inability to disable the hover on mobile.
Poor code readability
How easy the code is to read and understand has a direct impact on our performance as programmers. Keeping the code clean decreases the time we need to maintain it, and the chances of producing an unwanted behavior by introducing a bug.
Let’s see an example to illustrate the readability issue: a component written with utility classes and its counterpart with decoupled markup and styles. My apologies for yet-another-button-example, but its simplicity helps to illustrate the level of issues that a more complex component could contain and how that would scale in a large UI library.
First, utility classes:
const Button = ({ children, type, size }: ButtonProps): JSX.Element => (
<button
className={classnames(
"text-2xl font-bold underline lg:text-3xl bg-gray",
{ "h-7 text-3xl": size === "lg" },
{ "bg-blue": type === "secondary" },
{ "bg-black text-blue": type === "tertiary" }
)}
type="button"
>
{children}
<span
className={classnames(
"inline-block bg-black w-3 h-3 lg:w-4 lg:h-4",
{ "!w-6 !h-6": size === "lg" },
{ "!bg-blue": type === "tertiary" }
)}
/>
</button>
);
I can see the brevity understood as this is all you need in your component, but it turns to be considerably hard to infer the implicit logic: what happens when a property has a specific value, how many elements are affected by it, what is the final rendering for every case, especially if it has responsive behavior, how the CSS classes of the elements are related to each other…
Most likely we will need an external tool, a browser with devtools to render all the cases and to spend some time in manual testing before we start understanding the possible outcomes.
Now the example without utility classes, leaving the style details out of the markup:
const Button = ({
children,
type = null,
size = null,
}: ButtonProps): JSX.Element => (
<button
className={`button ${type} ${size}`}
type="button"
>
{children}
<span className="icon" />
</button>
);
This code is much cleaner and much easier to read because it contains only the information related to the markup in a format (JSX, after HTML) specifically suited to handle it. The same principle applies to the styles. They are separated in a CSS file which is also a specialized format to write and read them.
Even though the example is extremely simple, the readability issues are clear. At scale, in a complex website with several pages, many components, features per country, conditions based on feature flags or any other configuration, multilanguage, RTL support, animations, or other aspects, the complexity can increase to a level where the project becomes unworkable.
These are several advantages of the semantic approach versus utility classes in terms of readability:
- Understanding one thing at a time, rendering logic, markup, and styles, instead of a mix of the three of them coupled in the same file.
- Naming elements can be tedious but it effectively clarifies your code, as they describe it.
- A specialized format to style a document is always easier to read and process compared to long lists of classes written in a single never-ending long line.
- CSS offers solutions to group together related props: nesting, comma-separated selectors, media queries…
- CSS files have room for comments.
- Cleaner code is easier to predict, understand and maintain.
- Code reviews are simpler.
Flat specificity leads to non-predictable styles
In a low-level utility class framework, the specificity is always 1, even for the selectors inside a media query. With a flat specificity, the CSS precedence depends on a non-reliable factor: the order of the selectors in the CSS bundle.
In the following example, we can not predict what will be the color of the div when the type
value is secondary
since both bg-black
and bg-blue
have the same specificity. Both classes will apply and the final color will depend on a factor we can not control.
<div
className={classnames(
"bg-black",
{ "bg-blue": type === "secondary" },
{ "bg-green": type === "tertiary" }
/* non predictable background when type="secondary" or type="tertiary" */
)}
/>
We might be lucky and produce the correct behavior if bg-blue
is defined later in the final CSS bundle. But that also means that we will never be able to overwrite the colors the other way around. If we also need a bg-black
class overwriting a bg-blue
in another component we will never get it.
The only solution to fix the specificity issue safely is to add the !important
rule.
<div
className={classnames(
"bg-black",
{ "!bg-blue": type === "secondary" }
)}
/>
That would safely override the bg-black
styles with the bg-blue
ones. But this solution only allows a single overriding level.
If we would need to override a value twice, this approach would not work, as having two !important
would raise again the initial precedence problem.
<div
className={classnames(
"bg-black",
{ "!bg-blue": type === "secondary" }
{ "!bg-red": error }
/* non predictable background when type="secondary" and error={true} */
)}
/>
Non-supported new CSS features and custom styles
A utility classes framework abstracts the styles from the developer. We do not write CSS code, we write classes directly in the markup. By definition, having a custom framework in between the developer and the browser reduces the set of properties and values we can use: the available options are those the framework contains in the release we have loaded, while in CSS we can write any selector, property, and value that the browser can understand.
If at some point we need anything outside the framework: a new CSS feature that modern browsers support, an integration with a third party through custom props, or any specific CSS code… we will not be able to accomplish that unless we start writing CSS files along with the utility classes. That generates consistency problems and raises questions like when using utility classes, when using custom styles, and how to solve issues when both are applied at the same time.
A few examples:
Grid template areas are not supported by Tailwind, so a workaround is needed.
.container {
grid-template-areas: "a b";
}
The new CSS all property, a stage 4 feature, is not supported at the moment of writing this post. The same happens with other CSS features in the process of becoming web standards.
a {
all: initial;
}
Custom selectors like Context selectors can not be written with utility classes.
Complex learning curve
A low-level utility classes framework adds an extra layer of abstraction without reducing complexity. Tailwind utility classes do not prevent developers from needing to learn CSS. In addition to that, learning the counterpart Tailwind class is necessary as well, since the classes are matched 1 to 1.
For example, to position elements with flex
and row-reverse
in Tailwind utility classes, we first need to learn how to accomplish that in CSS:
.container {
display: flex;
flex-direction: row-reverse;
}
And later, learn, remember, or check the documentation to find the class names:
<div class="flex flex-row-reverse">
Of course, here I am assuming it is a bad idea to copy-pasting low-level classes without knowing what they do.
Less effective caching strategies
Typically, the caching strategies for markup and styles are different. The markup describes the content, and by definition, it will need to be fresh significantly often. We do not want to serve stale content. Once a new piece of content is added, it needs to be available as soon as possible to the users.
Yet, styles define how the content looks. In most cases, the style will not change as often as the content, by far. That allows developers to be more aggressive in caching styles and other assets, especially if the filenames are hashed, which will increase the website’s performance.
The Tailwind utility classes approach produces smaller CSS files, at the expense of having way more significant markup. That happens because the markup includes a class name per property for every element that needs style on a website, and potentially, that is a lot.
As an example to illustrate the weight of the markup with utility classes, I have taken the hero component from the Tailwind website. It includes only a few elements: a couple of paragraphs, a link, and a button with an SVG; in total, there are over 90 class names.
<div class="relative max-w-5xl mx-auto pt-20 sm:pt-24 lg:pt-32">
<h1 class="text-slate-900 font-extrabold text-4xl sm:text-5xl lg:text-6xl tracking-tight text-center dark:text-white">
Rapidly build modern websites without ever leaving your HTML.
</h1>
<p class="mt-6 text-lg text-slate-600 text-center max-w-3xl mx-auto dark:text-slate-400">A utility-first CSS framework
packed with classes like <code class="font-mono font-medium text-sky-500 dark:text-sky-400">flex</code>,
<code class="font-mono font-medium text-sky-500 dark:text-sky-400">pt-4</code>,
<code class="font-mono font-medium text-sky-500 dark:text-sky-400">text-center</code>and <code
class="font-mono font-medium text-sky-500 dark:text-sky-400">rotate-90</code>that can be composed to build any
design, directly in your markup.
</p>
<div class="mt-6 sm:mt-10 flex justify-center space-x-6 text-sm">
<a
class="bg-slate-900 hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:ring-offset-slate-50 text-white font-semibold h-12 px-6 rounded-lg w-full flex items-center justify-center sm:w-auto dark:bg-sky-500 dark:highlight-white/20 dark:hover:bg-sky-400"
href="/docs/installation">Get started</a>
<button type="button"
class="hidden sm:flex items-center w-72 text-left space-x-3 px-4 h-12 bg-white ring-1 ring-slate-900/10 hover:ring-slate-300 focus:outline-none focus:ring-2 focus:ring-sky-500 shadow-sm rounded-lg text-slate-400 dark:bg-slate-800 dark:ring-0 dark:text-slate-300 dark:highlight-white/5 dark:hover:bg-slate-700">
<svg
width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" class="flex-none text-slate-300 dark:text-slate-400" aria-hidden="true">
<path d="m19 19-3.5-3.5"></path>
<circle cx="11" cy="11" r="6"></circle>
</svg>
<span class="flex-auto">Quick search...</span><kbd class="font-sans font-semibold dark:text-slate-500"><abbr
title="Command" class="no-underline text-slate-300 dark:text-slate-500">⌘</abbr>K</kbd>
</button>
</div>
</div>
The CSS though will be lighter than it would be in non utility classes websites, since there will be just a unique declaration per class/property and the markup elements will share it.
Tailwind utility classes produce websites with fewer kilobytes of cacheable assets (the CSS) and more kilobytes of non-cacheable code (the markup) that have to be sent over and over to the browser in every single load even though it does not change a single comma.
That results in a poor caching strategy that affects negatively the performance, the server-side and client processing, the prefetching times, and several core metrics that impact directly the business.
Slower automated tests
Since the components markup will be considerably heavier with utility classes, not only the browser will be affected. Also, the testing tools will need to load and process more code, resulting in slower execution.
In a project where the automatic tests need to execute tens or hundreds of times per day, both in local and remote environments, slow tests can be an expensive deal, in terms of time, CPU processing, and budget.
Prone to errors testing
Tailwind utility classes describe the presentation of an element, not its semantics. If there is no other consideration for writing markup, a developer could be producing automated tests against purely presentational classes. Presentational classes are prone to change obviously more often than semantics.
Let’s take a very simple expandable component as an example.
const Expandable = ({expanded}: ExpandableProps): JSX.Element => (
<div
className={classnames(
"h-0",
{ "!h-32": expanded }
)}
>
{children}
</div>
);
The test would be:
expect(expandable.classList.contains("!h-32")).toBe(true)
This kind of presentational code inside automated testing smells bad from a very long distance. What if we remove the exclamation sign? What if we add responsive classes? What if we replace the arbitrary h-32
class name with an h-28
?
The lightest design decision might break a bunch of tests and require a considerable refactor.
On the contrary, a semantic version of that component decouples the markup and the presentation. A change in the styles would not affect the test at all.
const Expandable = ({expanded}: ExpandableProps): JSX.Element => (
<div
className={s.expandable}
aria-expanded={expanded}
>
{children}
</div>
);
The test would check the correct semantics while leaving the presentational behavior out of the equation.
expect(expandable).toHaveAttribute("aria-expanded", "true");
High cost refactoring the UI library
One of the requirements for a future-proof codebase is decoupling, understood as the ability of any of its parties to evolve or be refactored without breaking the system as long as it meets the contracts.
In the future, there are several reasons why we would need to change the framework behind our UI library or at least part of it: browsers could evolve and improve their support for Web components, new frameworks could be released, or old frameworks could die (it already happened with Backbone and Angular 1).
We can consider the first example of this article.
const Button = ({ children, type, size }: ButtonProps): JSX.Element => (
<button
className={classnames(
"text-2xl font-bold underline lg:text-3xl bg-gray",
{ "h-7 text-3xl": size === "lg" },
{ "bg-blue": type === "secondary" },
{ "bg-black text-blue": type === "tertiary" }
)}
type="button"
>
{children}
<span
className={classnames(
"inline-block bg-black w-3 h-3 lg:w-4 lg:h-4",
{ "!w-6 !h-6": size === "lg" },
{ "!bg-blue": type === "tertiary" }
)}
/>
</button>
);
Refactoring a coupled component like this one would require copying the markup, the rendering logic, and the class name conditions in every affected element. In this example, there are 2 affected elements and 5 conditions. Potentially there can be way more than that in a bigger component.
As a way of testing this, I have done the refactor to Vue.js and it is clearly tedious since not only the markup but also the styles and their conditions need to be rewritten.
app.component('app-button', {
props: {
type: String,
size: String,
},
template: `
<button
class="text-2xl font-bold underline lg:text-3xl bg-gray"
:class="{
'h-7 text-3xl': size === 'lg',
'bg-blue': type === 'secondary',
'bg-black text-blue': type === 'tertiary'
}"
type="button"
>
<slot />
<span
class="inline-block bg-black w-3 h-3 lg:w-4 lg:h-4"
:class="{
'!w-6 !h-6': size === 'lg',
'!bg-blue': type === 'tertiary'
}"
/>
</button>
`
});
In opposition to that, a non-couple component would require only refactoring the template, since the styles would be reused immediately.
const Button = ({
children,
type = null,
size = null,
}: ButtonProps): JSX.Element => (
<button
className={`button ${type} ${size}`}
type="button"
>
{children}
<span className="icon" />
</button>
);
The refactor is extremely simple. The markup is almost a copy-paste, and not prone to errors.
app.component('app-button', {
props: {
type: String,
size: String,
},
template: `
<button
class="button"
:class="[type, size]"
type="button"
>
<slot />
<span class="icon" />
</button>
`
});
Refactoring the CSS framework needs an enormous effort
Let’s imagine for a second that in a few years is Tailwind itself the tool that gets discontinued and becomes legacy. It stops supporting new CSS features and we need to replace it.
In that situation, the refactoring process would be something like this:
- Open a component.
- Take an HTML element that has classes.
- Name it with a semantic class name.
- Identify class names that are applied right away, the default ones.
- If some classes render under a condition, create a new semantic class name for them also conditioned.
- Transform every single class name to an actual CSS property including default styles, conditioned ones, media queries, pseudo elements, hovers…
- Repeat that for all the elements in that component.
- Make sure the component still behaves as expected. Adding specificity to the selectors might have messed things up.
- Check the resulting CSS is understandable. Maybe you want to group selectors in media queries, nest them, group them in comma-separated lists, or group the conditions affecting to several elements.
- Repeat it for every component in the whole UI library.
Applying that to the entire codebase, even if it is average-sized, is a process that can take hundreds of hours and is very prone to errors.
CSS, though, is a standard native technology that works directly on the browser. It needs for sure to be maintained and updated, but does not seem that it will need a complete refactor at any time soon.
Wrap up
- Tailwind utility classes can be good for situations where speed is a key factor, as long as the team has experience with them, knows the conventions, and understands the trade-offs. In future-proof applications, there are many issues.
- The readability of the code gets compromised and that comes at a high cost: more difficult code reviews, more time maintaining, and less clarity.
- Due to the flat specificity level (it is 1 for all the classes), many unwanted behaviors can happen, especially if the code follows the base-modifier approach: a base class with default styles and a modifier overwriting some of them.
- New CSS features can not be used until Tailwind adds support for them… unless you are willing to mix approaches: utility classes + custom CSS.
- Low-level utility classes don’t prevent a developer from needing to learn CSS, and on top of that, the class that represents every property.
- In terms of caching, usually, the markup has to be fresh very often, as new content needs to be available as soon as possible, while assets can have a more aggressive caching approach. Increasing the markup size produces non-cacheable code, which impacts directly the performance.
- Automated tests also get slower by loading big amounts of markup. That might impact the total costs in terms of time and budget if the project pays per-minute builds.
- Testing might be problematic if visual-related classes are checked, instead of semantic ones. A simple visual modification would trigger a class name change, and that might break a bunch of tests.
- Refactoring the UI library would take a considerable amount of time, as the utility classes can not be reused out of the box, in opposition to CSS.
- Once the project has a considerable size, replacing Tailwind with another CSS framework or approach can take enormous effort and cost.