Fixing Class Composition in Tailwind CSS
I’ve been going back and forth on Tailwind CSS for quite a while. On one hand, I loved the utility-first design, on the other - I hated how my HTML, JSX, etc. was getting bloated by the number of class names required to style even a basic button.
At last, Tailwind has won me over, mainly because it gets rid of one of the things I struggle with the most when writing CSS - naming. After all, as if designing good UI/UX wasn’t hard enough, you still have to find a good and architecturally-sound way to name all your classes - something that, for me at least, wasn’t achievable in the long run - no matter how many different CSS methodologies I tried.
On top of that, after using Tailwind for over a year now, for various projects, I found that it boosted my productivity. When paired with component-based framework utility classes work really well, especially when you have all of them memorized already. There is one caveat though…
Class Composition
Composing Tailwind’s utility classes is hard.
Consider you’re building an entire UI with basic components already having their styles defined through a set of Tailwind classes. What happens when you want to slightly alter the component just for a single use case? You can’t apply m-0 class because m-1 which the component already has applied, has higher specificity.
There are many opinions on how to deal with such cases. Some recommend using the @apply directive for your components’ base styles. Others argue that you should already consider all the possible variants of your component and use JavaScript logic to apply proper classes based e.g. on the component’s props.
For me, both of those solutions feel like compromises. I don’t like using @apply unless it’s absolutely necessary. On the other hand, thinking of all the variants ahead of time or including all of them in the base component either limits your designs or overuses client-side JS.
There are a couple of third-party solutions to this problem though.
Existing Alternatives
The thing is pretty much all existing solutions involve using more client-side JS. Thus, while they’re more developer-friendly than e.g. concatenating utility classes by hand, they somewhat unnecessarily slow down your website.
I’ve already covered some of the more JS-heavy alternatives in one of my previous posts - most, if not all of them are still actively maintained. For this article, I’ll focus more on the alternatives with limited use of JS and those I’ve newly discovered.
twin.macro
One of the more promising alternatives is twin.macro - a Babel macro that processes Tailwind classes to generate JS objects understandable by various CSS-in-JS libraries. The developer experience (DX) of using it is amazing as you not only get all of Tailwind’s features without much change to your code, but you also get much more flexibility - all that on top of the traditional benefits of CSS-in-JS. Here’s an example code:
import tw, { css } from "twin.macro";
const hoverStyles = css`
&:hover {
border-color: black;
${tw`text-black`}
}
`;
const Input = ({ hasHover }) => (
<input css={[tw`border`, hasHover && hoverStyles]} />
);
The problem is, twin.macro, while doing some processing during the build, still adds a lot of client-side JS - both by requiring a CSS-in-JS engine as well as putting all the style objects created through the macro into your JS code. This means not only slower performance, but also larger bundle sizes.
While twin.macro provided the best DX by far, there are a couple of other alternatives that got close to it, without adding as much additional JS code.
Vanilla-extract
Vanilla-extract allows you to utilize TypeScript as your CSS preprocessor, just like Sass. On top of that, it comes with Sprinkles - an atomic CSS framework that allows you to reproduce Tailwind’s utility classes.
// sprinkles.css.ts
import { defineProperties, createSprinkles } from "@vanilla-extract/sprinkles";
const colors = {
"blue-50": "#eff6ff",
"blue-100": "#dbeafe",
"blue-200": "#bfdbfe",
// etc.
};
const colorProperties = defineProperties({
conditions: {
lightMode: {},
darkMode: { "@media": "(prefers-color-scheme: dark)" },
},
defaultCondition: "lightMode",
properties: {
color: colors,
background: colors,
},
});
export const sprinkles = createSprinkles(responsiveProperties, colorProperties);
export type Sprinkles = Parameters<typeof sprinkles>[0];
The TS stylesheets must be defined in separate .css.js or .css.ts files though for “zero-runtime” usage. With that said, in order to achieve truly zero-runtime usage, you can only use Sprinkles in other TS stylesheets. This somewhat limits your flexibility.
Although you can use Sprinkles directly in your JS/TS code, a small runtime will be required - a helper function and a mapping object containing all your atomic classes, necessary for class lookup at runtime.
If you want to reproduce the entire set of utility classes from Tailwind, this object will be huge.
Windi CSS
Windi CSS is a direct Tailwind alternative (with full Tailwind CSS v2 feature compatibility), with easier setup and faster compile times. While the advantages of this framework lessened as Tailwind matured to v3, its most promising feature (in regards to class composition) was a compilation mode. It was meant to compile utility classes applied on elements into singular, scoped classes.
<!-- input -->
<div class="py-8 px-8 max-w-sm mx-auto bg-white rounded-xl shadow-md space-y-2 sm:py-4 sm:flex sm:items-center sm:space-y-0 sm:space-x-6">
<img class="block mx-auto h-24 rounded-full sm:mx-0 sm:flex-shrink-0" src="/img/erin-lindford.jpg" alt="Woman's Face">
<div class="text-center space-y-2 sm:text-left">
<div class="space-y-0.5">
<p class="text-lg text-black font-semibold">
Erin Lindford
</p>
<p class="text-gray-500 font-medium">
Product Engineer
</p>
</div>
<button class="px-4 py-1 text-sm text-purple-600 font-semibold rounded-full border border-purple-200 hover:text-white hover:bg-purple-600 hover:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-600 focus:ring-offset-2">Message</button>
</div>
</div>
<!-- output -->
<div class="windi-15wa4me">
<img class="windi-1q7lotv" src="/img/erin-lindford.jpg" alt="Woman's Face">
<div class="windi-7831z4">
<div class="windi-x3f008">
<p class="windi-2lluw6">
Erin Lindford
</p>
<p class="windi-1caa1b7">
Product Engineer
</p>
</div>
<button class="windi-d2pog2">Message</button>
</div>
</div>
If implemented well, this approach could fix all the issues related to class composition, while still providing DX similar to that of Tailwind. The only downside would be that, as described, it’d be an all-in or all-out solution that doesn’t allow you to pick which utility classes to “compile” and which to leave as is - leading to possibly large CSS bundles.
Still, this is by far the solution with the least negative impact. The only problem is, the development of the compile mode seems to have gone stale, leaving it available only to a few of Windi CSS dedicated integrations and barely documented. What’s worse, over the last few months development on the entire Windi CSS project has slowed down.
Zero-runtime solution
After exploring all the above alternatives (and more), I’ve decided to implement my own, custom solution. For that, I’ve turned to Linaria - a true zero-runtime CSS-in-JS library.
After Linaria does its processing in the build step, all you get is just a string in JS, while the actual styling is extracted into a dedicated CSS file. On top of that, it integrates well with most preprocessors, such as Sass or PostCSS - together with the PostCSS Tailwind plugin.
Thanks to that, using Linaria, I was able to get something like the following:
import { css } from "@linaria/core";
const flexCenter = css`@apply flex justify-center items-center`;
// Use in React JSX
<h1 className={flexCenter}>Hello world</h1>;
With Linaria and JSX I got my entire component - including styles and structure - in one file, while not sacrificing performance or code quality. The only problem left is with the syntax.
While not too long, css`@apply is a lot to repeat for every class you want to “compile”. That’s why using Vite and some Rollup plugins (Vite uses Rollup under the hood) I was able to shorten it into this:
const flexCenter = tw`@apply flex justify-center items-center`;
// Use in React JSX
<h1 className={flexCenter}>Hello world</h1>;
With no need for any additional imports and tw template literal tag syntax (inspired by twin.macro), this became my go-to solution for all projects using Tailwind.
To summarize the advantages of this solution over previous alternatives:
- Opt-in class name “compilation”;
- Truly zero-runtime (just strings);
- Still using Tailwind under the hood (with all its utility classes and access to the latest updates);
- Great DX (compact syntax, autocomplete in VS Code just by adjusting the regex in the Tailwind extension);
There is one drawback though - the setup. Linaria, while amazing in its functionality, turns out to be quite problematic to install, even with the available guides and integrations. I’ve almost always run into issues when setting it up in a new project. On top of that, if you want the shorthand tw-based syntax, you need some additional configuration as well:
import { defineConfig } from "vite";
import linaria from "@linaria/rollup";
import replace from "@rollup/plugin-replace";
import inject from "@rollup/plugin-inject";
export default defineConfig({
plugins: [
replace({
"tw`": `css\`@apply${" "}`,
preventAssignment: true,
}),
inject({
css: ["@linaria/core", "css"],
include: "**/*.ts?",
}),
linaria({
exclude: ["**/*.html", "**/*.css"],
}),
],
});
The above config is what I used to get the shorthand and Liniaria working in Vite. To give you an overview of how it works - the tw` is first replaced with css`@apply and then the css template literal tag is auto-imported where necessary. I’ve only used this setup with Vite and Astro (Vite-based) so I don’t know how hard it’d be to get it working in other bundlers (though pure Rollup should work as well).
Because of such a difficult setup and all the potential bug-fixing involved, it’s not something I’d recommend for everyone. With that said, I’m willing to go through it, just to later enjoy good DX, performant styling, and easy class composition. I haven’t yet explored extracting the entire setup into a separate Vite plugin, but it might be worth considering.
UnoCSS - the newest alternative
Now, I’d like to mention one more Tailwind alternative that might be worth your attention - especially in regards to class composition - UnoCSS.
UnoCSS seems to be the spiritual successor of Windi CSS, sharing some of its features and contributors. It’s an atomic CSS engine, meaning that it allows you to create your own utility CSS frameworks on top of it. Additionally, it’s super-fast, thanks partially to its architecture but also its general simplicity.
The best thing about UnoCSS is that it looks to be the best tool for atomic CSS, with various presets for defining utility classes (including one for Tailwind CSS) and great features, that can’t be found in Tailwind - including compilation mode!
That’s right, UnoCSS has a compilation mode, that’s even better than what was originally intended for Windi CSS, as it’s opt-in (with a configurable prefix), meaning you only compile the class names that you want!
<!-- input -->
<div class=":uno: text-center sm:text-left">
<div class="text-sm font-bold hover:text-red" />
</div>
<!-- output -->
<div class="uno-qlmcrp">
<div class="text-sm font-bold hover:text-red" />
</div>
UnoCSS seems like the go-to option to replace my scrappy setup. It’s working really well in my initial experiments and, in the future, I’ll likely move some, if not all, of my projects to UnoCSS.
Conclusion
This post is a result of a year or two of research, experimentation, and actual usage of different tools in various projects of mine. It didn’t even cover all alternatives I tested nor what’s possible with the likes of UnoCSS and other, “bleeding-edge” solutions. With that said, if you’re dealing with class composition and are in search of great development experience, I hope you found this article helpful!
Vrite
Blogging for developers | coming 2023
Join the waitlist now 👉 https://vrite.io
Professional editor and powerful headless CMS in one. Vrite is a writing platform by developers, for developers.
If you need
Custom Web App
I can help you get your next project, from idea to reality.