Variant Groups in Tailwind CSS
•9 min read
One of the things I like about build tools is the amount of customization it gives you to do “magical” things. With a correctly configured build tool, you can improve the life of a developer without them even realizing it! This is exactly what I explored very recently with Tailwind CSS and Vite. To add support for one of the most requested features in Tailwind CSS — Variant Groups using build tool modifications.
What’s a variant group?
Before going into what variant groups are, let’s first recall what “variants” are in Tailwind CSS.
Variants in Tailwind CSS are a way to apply certain utility styles when a particular condition defined by that variant is met. There are a ton of variants available,
from pseudo selectors like hover:
, focus:
, to breakpoints like sm:
, lg:
, to crazier ones like dark:
for applying styles when dark mode is turned on.
Thus, we can make use of these variants to style various states of a particular element declaratively.
Here’s an example of an input element in React styled with Tailwind CSS (this one is right from their
1<input2type="text"3className="mt-1 block w-full px-3 py-2 bg-white border4border-slate-300 rounded-md text-sm shadow-sm5placeholder-slate-40067focus:outline-none focus:border-sky-500 focus:ring-18focus:ring-sky-500910disabled:bg-slate-50 disabled:text-slate-50011disabled:border-slate-200 disabled:shadow-none1213invalid:border-pink-500 invalid:text-pink-6001415focus:invalid:border-pink-500 focus:invalid:ring-pink-500"16/>
That’s a looooong list of classes! This isn’t even complete yet, we have to stack other variants for dark mode support, responsive styles, etc. and the list only grows from here.
If you take a look at the classes again, you’ll notice that a few things are repeated multiple times. Yes, its focus:
, disabled:
, invalid:
, and
focus:invalid:
variants repeat multiple times for each utility class. Can we reduce this duplication? It should reduce the cumbersome of typing each of these variants
multiple times and also make the code a bit more terse and easy to read.
👋 Say hello to Variant groups!
Variant groups solve exactly this. It’s a way to group utility classes sharing a common variant to eliminate the repeating variant applied to individual utility
classes. Classes like hover:bg-purple-500 hover:text-white
become hover:(bg-purple-500 text-white)
where bg-purple-500
and text-white
are implicitly a part of
the hover:
variant that has been collectively applied to the group.
Using variant groups, the code above for the input can be updated to the following:
1<input2type="text"3className="mt-1 block w-full px-3 py-2 bg-white border4border-slate-300 rounded-md text-sm shadow-sm5placeholder-slate-40067focus:(outline-none border-sky-500 ring-1 ring-sky-500)89disabled:(bg-slate-50 text-slate-500 border-slate-200 shadow-none)1011invalid:(border-pink-500 text-pink-600)1213focus:invalid:(border-pink-500 ring-pink-500)"14/>
This is easier for both the eyes and the fingers which type them! It brings in a subtle organization in the code too, with styles for a particular state grouped neatly
in a single place. As good as it looks, Tailwind CSS does not currently support variant groups like this. There are various
Why don’t variant groups work?
This is quite easy to explain. What we’ve just done with grouping multiple classes into a single unit is not a concept defined in the CSS spec. A class name string
like invalid:(border-pink-500 text-pink-600)
is interpreted as two different classes:
invalid:(border-pink-500
,text-pink-600)
There’s no concept of grouping with parenthesis and the second class above does not have any idea about the variant context it is in(here invalid:
).
Thus, even if Tailwind CSS was somehow able to generate the correct styles for the above classes, the browser won’t understand how these individual classes are related.
But hey, Windi CSS already does this!
Yes,
But that’s not the complete solution. If you are already using Tailwind CSS and moving to Windi CSS is difficult(as both are technically built in different ways), we need some approach(and hacks) to get variant groups working in Tailwind CSS.
Build tools to the rescue
The way we can solve the above browser limitation is by expanding the class names to the long format so that the browser can understand them. The developer gets to write short-form variant group syntax, but the browser magically gets the long-form expanded syntax which it is already able to understand.
invalid:(border-pink-500 text-pink-600)
gets converted to
invalid:border-pink-500 invalid:text-pink-600
automagically!
When this conversion happens is an important decision to make. We could have a simple JS function that does this conversion in the runtime, or we can also leverage the build tools and do this conversion at the build time itself. The advantage of doing it in the build time is that it reduces the overhead at the runtime and allows you to do more important things rather than transforming class names! So, let’s go ahead and write some build time configs to perform these transformations.
Tailwind CSS config changes
Currently, even Tailwind CSS does not know what classes to generate when it sees a variant group. It knows what invalid:border-pink-500
and invalid:text-pink-500
mean
and what their associated styles are, but it doesn’t know what invalid:(border-pink-500 text-pink-500)
is or what the styles for them would look like.
We also have to tell Tailwind CSS to look at our source code as the expanded syntax and not the variant group syntax, and for this, we can make use of
1module.exports = {2content: {3// Yes! `content` can be an object!45files: ['./index.html', './src/**/*.{js,jsx,ts,tsx}', ...],6transform: (code) => {78// We'll write the transformation logic here!910return code;11}12}13}
Now we have to write some logic to perform the short-form to expanded-form conversion. The code
argument at line 6 is a string, so you could parse this to an
AST, walk the AST, and make the transformations that way(it is probably more scalable and reliable), but I’ll be using simple regular expressions in this post
to make direct string manipulations to keep things simple.
1module.exports = {2content: {3files: ['./index.html', './src/**/*.{js,jsx,ts,tsx}', ...],4transform: (code) => {5const variantGroupsRegex = /([a-z\-0-9:]+:)\((.*?)\)/g;6const variantGroupMatches = [...code.matchAll(variantGroupsRegex)];78variantGroupMatches.forEach(([ matchStr, variants, classes ]) => {9const parsedClasses = classes10.split(' ')11.map((cls) => variants + cls)12.join(' ');1314code = code.replace(matchStr, parsedClasses);15});16return code;17}18}19}
The above code uses a regex in line 6 to extract each of the variant groups. Then for every class in a variant group, it adds the variant as a prefix to the class to generate the expanded classes at like 12. Finally, in line 15, the short-form class names are replaced with the expanded ones that we just generated.
Note: The regular expression defined in this logic might match and replace some non-class-names part of the JS code which could lead to unforeseen consequences. But since only Tailwind CSS reads the transformed code here to generate the styles, it is somewhat safe. More on this in the later sections.
Tailwind CSS should now be able to generate the correct styles for all these classes. The next thing that needs changes is the build pipeline. Changing how the JS files are bundled and built would allow us to send expanded-form classes to the browser — which it would be able to understand as explained earlier.
Writing a custom build plugin for transforming JS code
At this point, you should probably look the build pipeline for your JS / TS source files and figure out how you could customize it and include custom code transformation logic. I’ve been using Vite for the demo in this post, so I’ll write a plugin that can be used with Vite / Rollup. But if you are using Webpack or any other build tool, do take a look at the respective docs on how you could write a custom code transformation plugin.
In the vite.config.js
file, let’s add a function called twVariantGroups
, which is the name of our custom plugin:
1import { defineConfig } from 'vite';2import react from '@vitejs/plugin-react';34function twVariantGroups() {5return {6name: 'tw-variant-groups',7transform(code) {8// Write code transformation logic here!910return code;11},12};13}1415export default defineConfig({16plugins: [twVariantGroups(), react()],17});
Now we need to add some code transformation logic that would convert the short-form variant group syntax to the expanded class names that the browser can understand.
Can we use the same logic from the Tailwind CSS config file?
It seems like we are doing the same thing here, but since this is a build plugin we have to be extra careful about the substrings we are matching and replacing, as
replacing unexpected parts of the JS code can make it syntactically / semantically incorrect leading to unexpected errors.
We are only interested in modifying the static class names. And since I’m using React in the demo, I would need to apply the transformations to
the value of the className
prop in the code. An important observation here is that the code
the build plugin receives(in the case of Vite) is transpiled JSX code.
Something like:
1<input className="bg-gray-200 text-gray-800" ... />
gets converted to:
1jsx("input", {2className: "bg-gray-200 text-gray-800",3...4});
and is then sent to our custom build plugin. Thus, our regular expression should match the className
properties in objects and make the transformations there.
This is the code for the same:
1function twVariantGroups() {2return {3name: 'tw-variant-groups',4transform(code) {5const classNameRegex = /className\s*:\s*\"(.*?)\"/gm;6const classNameMatches = [...code.matchAll(classNameRegex)].filter(7(match) => match && match.length8);910classNameMatches.forEach(([matchStr, className]) => {11const parsedClasses = (code = code.replace(matchStr, parsedClasses)); // Use `className` to generate the expanded classes12});1314return code;15},16};17}
A regular expression is used to match the className
properties and their respective value. All matches for this regex are found in the source code,
and for each match, we can use the value in the className
variable to convert it to the expanded class names form (from the short-form variant group syntax).
How do we make this conversion?
Well, we already wrote the code for this in the Tailwind CSS config file! Let’s extract this as a function and reuse it here.
1const parseVariants = (code) => {2const variantGroupsRegex = /([a-z\-0-9:]+:)\((.*?)\)/g;3const variantGroupMatches = [...code.matchAll(variantGroupsRegex)];45variantGroupMatches.forEach(([ matchStr, variants, classes ]) => {6const parsedClasses = classes7.split(' ')8.map((cls) => variants + cls)9.join(' ');1011code = code.replace(matchStr, parsedClasses);12});1314return code;15};1617module.exports = {18content: {19files: ['./index.html', './src/**/*.{js,jsx,ts,tsx}', ...],20transform:parseVariants,21}22}
And then, we can make use of parseVariants()
function in the vite.config.js
file too!
1const parseVariants = (code) => {2const variantGroupsRegex = /([a-z\-0-9:]+:)\((.*?)\)/g;3const variantGroupMatches = [...code.matchAll(variantGroupsRegex)];45variantGroupMatches.forEach(([ matchStr, variants, classes ]) => {6const parsedClasses = classes7.split(' ')8.map((cls) => variants + cls)9.join(' ');1011code = code.replace(matchStr, parsedClasses);12});1314return code;15};1617function twVariantGroups() {18return {19name: 'tw-variant-groups',20transform(code) {21...2223classNameMatches.forEach(([ matchStr, className ]) => {24const parsedClasses = parseVariants(className);2526code = code.replace(matchStr, parsedClasses);27});2829return code;30}31}32}
The parseVariants()
function can be extracted to a separate file and imported in the tailwind.config.cjs
and vite.config.js
files, but I’ve kept a copy of the
code in both files for simplicity and clarity here.
The changes are almost done. We have a small issue hiding in plain sight at line 27. If you played around with the classNameRegex
, you’d have figured out by now
what the issue is! The replacement being performed is replacing more code than what we want.
After performing the transformation on the following transpiled code:
1jsx('input', {2className: 'invalid:(border-pink-500 text-pink-500)',3});
it ends up looking like this:
1jsx("input", {2invalid:border-pink-500 invalid:text-pink-500,3});
This is syntactically invalid JS code! The browser won’t be able to execute it. Let’s add a fix for this:
1...23function twVariantGroups() {4return {5name: 'tw-variant-groups',6transform(code) {7...89classNameMatches.forEach(([ matchStr, className ]) => {10const parsedClasses = parseVariants(className);1112code = code.replace(matchStr, `className: "${parsedClasses}"`);13});1415return code;16}17}18}
Now the code after all the replacements look correct. We retain the className:
and the double quotes surrounding the value.
Trying the changes 🤞
Finally! Let’s try all the changes we made to see if variant groups work as expected.
Notice how in the editor, I am using variant groups in the code, but the browser inspector shows the complete expanded class names. And all the styles seem to have been applied correctly! Hurray! 🎉
We can now make use of variant groups to group all related utility classes under a single variant. We can stack the variants and nest other variants in a group. You can take this approach further to support nested group variants, and other fancy things:
1<div2className="dark:(3text-gray-3004sm:(text-lg text-gray-100)5[&>span]:(text-rose-200 underline)6)"7>8Hello9<span>World</span>10</div>
Isn’t this a hack? 🤨
This might seem like a hack. Probably because not everyone’s used to modifying a build pipeline and using regular expressions to surgically modify parts of their code! But things like these are what’s powering your fancy JS app under the hood 😛.
Ok jokes aside, While I don’t consider this approach a hack, I do think it is brittle. While it works for
the small demo project I showed, it might not scale well
for very large projects. You might have cases where the regex does not cover some class names, or it might be matching and transforming other
parts of the JS code leading to unexpected errors. It currently does not support dynamic values for the className
prop. And what if you store the classes
in a variable and assign that variable as a prop?
All of these are cases the approach I shared did not account for to keep things simple. You could improve it by actually using ASTs to do the transformations instead of using regular expressions. That would guarantee some safety, but even then, who knows what small edge case you probably forgot to cover!
Conclusion
This blog post went over what variant groups are, and how we can leverage build tools to add support for variant groups in Tailwind CSS. Yes, while the approach I shared might not be the most scalable, I hope you can appreciate the power and flexibility build tools bring for improving the developer experience. Who knows, maybe Tailwind CSS will add support for this feature in the future using a similar build-tool-based approach which covers a lot more edge cases!
References
- Tailwind CSS:
tailwindcss.com - Windi CSS:
windicss.org - Vite Plugin API:
vitejs.dev/guide/api-plugin.html#plugin-api - RegExr (Regular expression playground):
regexr.com