Variant Groups in Tailwind CSS

13 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 docs):

jsx
1
<input
2
type="text"
3
className="mt-1 block w-full px-3 py-2 bg-white border
4
border-slate-300 rounded-md text-sm shadow-sm
5
placeholder-slate-400
6
7
focus:outline-none focus:border-sky-500 focus:ring-1
8
focus:ring-sky-500
9
10
disabled:bg-slate-50 disabled:text-slate-500
11
disabled:border-slate-200 disabled:shadow-none
12
13
invalid:border-pink-500 invalid:text-pink-600
14
15
focus: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:

jsx
1
<input
2
type="text"
3
className="mt-1 block w-full px-3 py-2 bg-white border
4
border-slate-300 rounded-md text-sm shadow-sm
5
placeholder-slate-400
6
7
focus:(outline-none border-sky-500 ring-1 ring-sky-500)
8
9
disabled:(bg-slate-50 text-slate-500 border-slate-200 shadow-none)
10
11
invalid:(border-pink-500 text-pink-600)
12
13
focus: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 discussions around this topic but most of them lead to an unfortunate dead-end.

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, Windi CSS already has this exact feature we are looking for, and it works really well out-of-the-box. So how is it able to solve the above limitation? Should I just use Windi CSS?! My answer to the second question would be a yes if you can 😛, as Windi CSS has almost complete feature-parity with Tailwind CSS along with some extra features(like Variant groups).

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.

tailwind
invalid:(border-pink-500 text-pink-600)

gets converted to

tailwind
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 transform() method to transform the code Tailwind CSS sees to generate the classes.

tailwind.config.cjs
js
1
module.exports = {
2
content: {
3
// Yes! `content` can be an object!
4
5
files: ['./index.html', './src/**/*.{js,jsx,ts,tsx}', ...],
6
transform: (code) => {
7
8
// We'll write the transformation logic here!
9
10
return 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.

tailwind.config.cjs
js
1
module.exports = {
2
content: {
3
files: ['./index.html', './src/**/*.{js,jsx,ts,tsx}', ...],
4
transform: (code) => {
5
const variantGroupsRegex = /([a-z\-0-9:]+:)\((.*?)\)/g;
6
const variantGroupMatches = [...code.matchAll(variantGroupsRegex)];
7
8
variantGroupMatches.forEach(([ matchStr, variants, classes ]) => {
9
const parsedClasses = classes
10
.split(' ')
11
.map((cls) => variants + cls)
12
.join(' ');
13
14
code = code.replace(matchStr, parsedClasses);
15
});
16
return 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:

vite.config.js
js
1
import { defineConfig } from 'vite';
2
import react from '@vitejs/plugin-react';
3
4
function twVariantGroups() {
5
return {
6
name: 'tw-variant-groups',
7
transform(code) {
8
// Write code transformation logic here!
9
10
return code;
11
},
12
};
13
}
14
15
export default defineConfig({
16
plugins: [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:

jsx
1
<input className="bg-gray-200 text-gray-800" ... />

gets converted to:

js
1
jsx("input", {
2
className: "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:

vite.config.js
js
1
function twVariantGroups() {
2
return {
3
name: 'tw-variant-groups',
4
transform(code) {
5
const classNameRegex = /className\s*:\s*\"(.*?)\"/gm;
6
const classNameMatches = [...code.matchAll(classNameRegex)].filter(
7
(match) => match && match.length
8
);
9
10
classNameMatches.forEach(([matchStr, className]) => {
11
const parsedClasses = (code = code.replace(matchStr, parsedClasses)); // Use `className` to generate the expanded classes
12
});
13
14
return 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.

tailwind.config.cjs
js
1
const parseVariants = (code) => {
2
const variantGroupsRegex = /([a-z\-0-9:]+:)\((.*?)\)/g;
3
const variantGroupMatches = [...code.matchAll(variantGroupsRegex)];
4
5
variantGroupMatches.forEach(([ matchStr, variants, classes ]) => {
6
const parsedClasses = classes
7
.split(' ')
8
.map((cls) => variants + cls)
9
.join(' ');
10
11
code = code.replace(matchStr, parsedClasses);
12
});
13
14
return code;
15
};
16
17
module.exports = {
18
content: {
19
files: ['./index.html', './src/**/*.{js,jsx,ts,tsx}', ...],
20
transform:parseVariants,
21
}
22
}

And then, we can make use of parseVariants() function in the vite.config.js file too!

vite.config.js
js
1
const parseVariants = (code) => {
2
const variantGroupsRegex = /([a-z\-0-9:]+:)\((.*?)\)/g;
3
const variantGroupMatches = [...code.matchAll(variantGroupsRegex)];
4
5
variantGroupMatches.forEach(([ matchStr, variants, classes ]) => {
6
const parsedClasses = classes
7
.split(' ')
8
.map((cls) => variants + cls)
9
.join(' ');
10
11
code = code.replace(matchStr, parsedClasses);
12
});
13
14
return code;
15
};
16
17
function twVariantGroups() {
18
return {
19
name: 'tw-variant-groups',
20
transform(code) {
21
...
22
23
classNameMatches.forEach(([ matchStr, className ]) => {
24
const parsedClasses = parseVariants(className);
25
26
code = code.replace(matchStr, parsedClasses);
27
});
28
29
return 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:

js
1
jsx('input', {
2
className: 'invalid:(border-pink-500 text-pink-500)',
3
});

it ends up looking like this:

js
1
jsx("input", {
2
invalid: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:

vite.config.js
js
1
...
2
3
function twVariantGroups() {
4
return {
5
name: 'tw-variant-groups',
6
transform(code) {
7
...
8
9
classNameMatches.forEach(([ matchStr, className ]) => {
10
const parsedClasses = parseVariants(className);
11
12
code = code.replace(matchStr, `className: "${parsedClasses}"`);
13
});
14
15
return 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.

Variant groups working in Tailwind CSS

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:

jsx
1
<div
2
className="dark:(
3
text-gray-300
4
sm:(text-lg text-gray-100)
5
[&>span]:(text-rose-200 underline)
6
)"
7
>
8
Hello
9
<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

If you found this article helpful, you will love these too.

Building highlighted input field in React

12 min read
Building highlighted input field in React
My look at SvelteJS and how you can start using it
Full-page theme toggle animation with View Transitions API