Just-in-Time friendly style variants in Tailwind CSS UI components (Part 1)

The art of writing dynamic styles that play nice with Tailwind’s Just-in-Time engine

Time to read
~9 minutes

Imagine that you’re building a UI component with React and Tailwind CSS.

That component needs to support multiple style variants.

A fairly common instinct here would be to construct dynamic classes based on properties passed to the component.

Here’s a simplified example:


// Component use
<Text color="purple" size="lg">I should be purple and large text.</Text>
// Component implementation
export function Text({color, size, children}) {
return (
<p className={`text-${color}-700 text-${size} ...`}>
{children}
<p>
)
}

We’re creating dynamic classes by interpolating the color and size prop values, which may sound like a good idea.

But it won’t work with Tailwind CSS.

Unless the text-purple-700 and text-lg classes are present somewhere else in our markup, these classes will not be generated, and the expected styles will not be applied.

Damn. But why?

Great question.

To answer this, let’s talk about Tailwind’s Just-in-Time engine.


Just-in-Time compilation

Tailwind CSS v3 generates styles on demand, based on what classes are being detected in your templates.

This ensures that even during development, you never deal with more CSS than you need to.

That translates to very fast build times and opens doors for many cool features that would simply not be possible if all permutations of utility classes were built upfront in a giant CSS file.

Want to add a bunch of new colors? A few extra breakpoints? Maybe add some custom variants to your config file?

No problem.

Tailwind only generates the CSS for the classes you’re using in your project.

The Just-in-Time engine (JIT) is why all colors, variants, and dark mode are enabled out of the box in Tailwind CSS v3.

Oh, and these variants can all be stacked together, too. That’s a lot of cumulated permutations 😅

But that’s totally fine – there is no need to ever worry about a bloated CSS output.

This is awesome.

But, how exactly does the JIT engine determine what classes are actually used?

How Tailwind looks at your code

The way the JIT engine scans your project to figure out what CSS classes to generate is intentionally simple and relies on two key principles:

  1. You need to tell Tailwind where to look in your config’s content option
  2. Tailwind will only find classes that exist in a full, uninterrupted string in the files it’s looking at.

That second principle is the reason our <Text /> example is not working properly.

When writing a text-${color}-700 template string in JavaScript, we expect the outcome to be text-purple-700, given a color prop of purple.

Matter of fact - if you’ve tried this and inspected the <p> tag generated in your browser, you will see that exact class, text-purple-700, applied to the paragraph tag.

It’s there, in the browser.

But it’s not anywhere, as far as Tailwind’s JIT engine is concerned.

Tailwind doesn’t evaluate your JavaScript code. It looks at everything as plain text, no matter what programming language the file is written in.

Just like if everything was a .txt file.

Fun fact, the following code will generate the text-purple-700 class:

<p>My favorite class in Tailwind is text-purple-700, I love it.</p>

The class is there, in its entirety, in plain text. Therefore, it will get generated.

And that’s the one and only rule the JIT engine lives by.

Can I find this Tailwind class in a full string as plain text? 👀 If yes, I’ll generate the CSS for it 👍 If not, I won’t 🤷‍♀️

So... I can’t use dynamic style?

Wait, no – of course, you can!

You just have to be a little creative and find ways to provide dynamic styles in a JIT-friendly way.

Good news: that’s what we’re going to do now!

Let’s build a relatively simple Button component. That component will support multiple style variants in a way that works great with Tailwind’s JIT engine on-demand generation strategy 🎉

Let’s go!

A JIT-friendly, multi-style Button component

We’ll keep things relatively simple. Our Button will support the following style variants:

  • 4 variant visual styles: primary, secondary, danger and text
  • 3 size options: small, medium, large

Here’s what our button variants will look like:

Button Variants

Let’s start by scaffolding the Button component.

We’ll accept two props, variant and size, and set some default values for those, to make those optional:


export const Button = (props) => {
const { variant, size, ...rest } = props
return <button {...rest} className="" />
}
Button.defaultProps = {
variant: 'primary',
size: 'medium'
}

Note that our className attribute is currently empty for now.

We will use the variant and size prop values to decide what Tailwind classes should go in there.

Remember: we need to make sure every single Tailwind class we’re using is present, somewhere, as a full text string for the JIT engine to find.

There are many ways to achieve this, but one approach I particularly enjoy is to use “lookup” objects.

They’re just JavaScript objects where the keys represent the different possible prop variants.

The values represent a string of composed Tailwind utility classes that achieve the desired styles for that particular variant.

Here’s what such a lookup object would look like for the variant prop:


const variantsLookup = {
primary: 'bg-cyan-500 text-white shadow-lg ...',
secondary: 'bg-slate-200 text-slate-800 shadow ...',
danger: 'bg-red-500 text-white shadow-lg ...',
text: 'text-slate-700 uppercase underline ...',
}

Same deal for the size prop:


const sizesLookup = {
small: 'px-3 py-1.5 text-sm ...',
medium: 'px-5 py-3 ...',
large: 'px-8 py-4 text-lg ...',
}

Tailwind will definitely look at these lookup tables 👀

The whole idea behind this approach is to ensure that every single class you use for your different variants is listed somewhere as a plain text string.

These lookup objects are sort of an inventory of every class you’re using across every possible style permutation.

You will enjoy looking at those too!

From a developer standpoint, lookup objects are also great: you can “see” at a glance, in a clutter-free way, what every style variant will look like.

That’s much easier than trying to read through a series of ternary operators or other conditional logic JavaScript inside your className attribute! 😅

But what about common styles between all variants?

No matter what component you’re building, you’ll inevitably have some classes that need to be applied to every style variant, regardless of the permutation of props.

It’s a good idea to identify those classes and abstract them away.

These could stay in the Button’s className attribute directly, but it’s kinda nice to store them in their own variable, sort of a “mini lookup” of its own, so everything style-related is colocated.

There aren’t many common styles to all variants in our Button component case.

Here’s what it looks like:


const baseClasses = 'rounded-md font-medium focus:outline-none'

That’s it.

We’ll handle focus styles in each variant, so it makes sense to opt-out of the default focus outline in our baseClasses list.

Beware of competing classes between different variant types!

One thing to look out for is that you don’t have competing styles between different variant types.

For example, you may have:

  1. the text-lg class in variant.primary
  2. the text-sm class in size.small

Situations like this could lead to sneaky and unexpected results.

It’s worth spending some time thinking about splitting the style concerns of each style variant.

And make sure you have a clean separation between those.

Our styles inventory

In the case of our Button, here’s what our complete styles inventory - our lookup objects - look like:


const baseClasses = 'rounded-md font-medium focus:outline-none'
const variantsLookup = {
primary: 'bg-cyan-500 text-white shadow-lg hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan-500',
secondary: 'bg-slate-200 text-slate-800 shadow hover:bg-slate-300 focus:bg-slate-300 focus:ring-slate-500',
danger: 'bg-red-500 text-white shadow-lg uppercase tracking-wider hover:bg-red-400 focus:bg-red-400 focus:ring-red-500',
text: 'text-slate-700 uppercase underline hover:text-slate-600 hover:bg-slate-900/5 focus:text-slate-600 focus:ring-slate-500',
}
const sizesLookup = {
small: 'px-3 py-1.5 text-sm focus:ring-2 focus:ring-offset-1',
medium: 'px-5 py-3 focus:ring-2 focus:ring-offset-2',
large: 'px-8 py-4 text-lg focus:ring focus:ring-offset-2',
}

100% of the Tailwind classes used to build these buttons are present in this inventory.

All in plain text.

100% JIT-friendly ⚡️

All we need to do now is correctly compose those styles together in our Button’s className attribute.

Composing style variants together

This step is relatively straightforward.

Essentially, we want to “merge” Tailwind classes from our baseClasses with the appropriate variant and size classes from our lookup objects.

Our lookup objects represent each possible value for both these props.

That means we can use our variant and size prop value to reach for the correct key in both those lookup objects, like so:

  1. variantsLookup[variant]
  2. sizesLookup[size]

We want to merge these two strings of classes with our baseClasses.

We’re combining three strings together, so we can do this in a template string:


`${baseClasses} ${variantsLookup[variant]} ${sizesLookup[size]}`

That’s it! This combined string of class will dynamically adjust to the props passed to our component. This will take care of applying the right styles for any permutation of these props 🎉

Let’s put that string in our Button's className attribute:


export const Button = (props) => {
const { variant, size, ...rest } = props
return <button {...rest} className={`${baseClasses} ${variantsLookup[variant]} ${sizesLookup[size]}`} />
}

Annnd... voila! Our Button component now supports the styles for all the different variants.

Here’s the entire code for our component:


const baseClasses = 'rounded-md font-medium focus:outline-none'
const variantsLookup = {
primary: 'bg-cyan-500 text-white shadow-lg hover:bg-cyan-400 focus:bg-cyan-400 focus:ring-cyan-500',
secondary: 'bg-slate-200 text-slate-800 shadow hover:bg-slate-300 focus:bg-slate-300 focus:ring-slate-500',
danger: 'bg-red-500 text-white shadow-lg uppercase tracking-wider hover:bg-red-400 focus:bg-red-400 focus:ring-red-500',
text: 'text-slate-700 uppercase underline hover:text-slate-600 hover:bg-slate-900/5 focus:text-slate-600 focus:ring-slate-500',
}
const sizesLookup = {
small: 'px-3 py-1.5 text-sm focus:ring-2 focus:ring-offset-1',
medium: 'px-5 py-3 focus:ring-2 focus:ring-offset-2',
large: 'px-8 py-4 text-lg focus:ring focus:ring-offset-2',
}
export const Button = (props) => {
const { variant, size, ...rest } = props
return <button {...rest} className={`${baseClasses} ${variantsLookup[variant]} ${sizesLookup[size]}`} />
}
Button.defaultProps = {
variant: 'primary',
size: 'medium',
}

On the other side, what does it look like when “consuming” the component?

Here’s what actually using this component feels like for the developer:


import { Button } from 'path/to/button'
export default function Page() {
return (
<>
<Button variant="secondary" size="small">Small, secondary button</Button>
<Button>Medium, primary button</Button>
<Button variant="danger" size="large">Large, danger button!</Button>
</>
)
}

Notice the second Button has no props at all.

Here’s what these three buttons look like in our browser:

3 buttons

Nice!

Since we have set defaultProps, it will use the primary variant if no variant prop is passed. And if no size prop is passed, it will use the medium size.

Nifty!

Where are the docs, though?

Our Button component is nice and all, but where can developers read about what props are available and what values are expected for each props?

Should we create a documentation website?

Add prop-types?

What about TypeScript?

What would you do to improve the situation and give the consumer of our component a few more hints on how to use our Button?

Here’s a link to the code representing where we’re right now:

https://github.com/pro-tailwind/JIT-friendly-buttons/tree/main/part-1

Why don’t you play with it and see if you can improve the prop documentation situation?

Continue Reading
Just-in-Time friendly style variants in Tailwind CSS UI components (Part 2)

Autocomplete suggestions and error messages with lightweight TypeScript