Building React Components with Variants Using Tailwind CSS

Using Tailwind CSS is a great framework for quickly building rich user
interfaces with it's extensive utility first classes, however, adding style
variants to custom components can quickly become complex to manage without a
good strategy.
In this article I'll be covering some strategies for building components with variants using Tailwind CSS, including:
- What are component variants?
- How to add variants
- Compound variants
- Creating reusable variants
- Strategies for good component composition
What are Tailwind CSS component variants?
Tailwind CSS has the concept of variants at the plugin level but component variants in this case are at the component level.
Component variants are React props
with type-safe preset options that change a component's styles by managing what Tailwind CSS utility classes get applied based on the prop value.
Why are Tailwind CSS component variants important?
Having a good strategy to apply Tailwind CSS class variants will improve the following:
- Makes components easier to read without tons of logic to add or remove classes.
- Maintains a uniform consistency in naming and variant options.
- Type-safe variant options.
- Better overall component composition.
- Variants can be resusable for other components
Prerequisites
I'll assume that you have a React app with Tailwind CSS already configured. If not head over and get that set up first.
Required libraries
Also, you'll need to install some packages:
cva
: class variance authority which is a lightweight, type-safe, CSS-in-TS library to extract and apply Tailwind CSS variant classes very easily.
clsx
: A library to conditionally apply classes and also used by cva
internally.
tailwind-merge
: Utility function to merge Tailwind CSS classes without potential style conflicts.
npm i cva clsx tailwind-merge
Merging Tailwind CSS
When applying several Tailwind CSS utility classes it has become good practice to use a merge utility to avoid any style conflicts. The tailwind-merge
utility library does this very efficiently.
We'll add a cn
utility function which is short for className
. You can rename to your preference. This function will do all of the merging for us using the tailwind-merge
and clsx
functions.
utils.ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Tailwind CSS Intellisense
Lastly, if you don't have a Tailwind CSS code intellisense extension, I highly recommend adding Tailwind CSS IntelliSense
Visual Studio Code extension.
Adding the following regex lines to your Visual Studio Code settings.json
will allow for Tailwind CSS intellisense for classes defined inside the cva()
and cn()
functions we'll be using.
settings.json
{
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
],
}
Getting Started with Tailwind CSS Variants
For this article, I figured we could make something useful like CTA buttons. These are buttons that are commonly used in marketing and they should be big, fun and attractive and call a user's attention to take an action.
For example:
And here's a sample how the JSX would look like for these components.
<div class="w-[300px] flex justify-between flex-col gap-4">
<DemoCtaButton bgColor="green" shadow="inner">Register Now</DemoCtaButton>
<DemoCtaButton bgColor="blue" shadow="md">Sign-up For Newsletter</DemoCtaButton>
<DemoCtaButton bgColor="purple" rounded="full" shadow="lg">Get Started</DemoCtaButton>
<DemoCtaButton bgColor="slate" rounded="none">Enroll Today</DemoCtaButton>
</div>
Using CVA variants with Tailwind CSS
Using class variance authority (cva)
is a great way to easily apply Tailwind CSS classes or even groups of classes based on props
options.
Defining CVA variants
The cva
function takes two arguments:
base
: Any base classes that apply to component.
config
: The config is an object that consists of the following:
variants
: Object containing variant keys and options
defaultVariants
: (Optional) Default selected variant option values
compoundVariants
: (Optional) Applies classes when multiple combinations of variant props are set.
For compoundVariants
, I'll just add some entries to conditionally toggle the font color based on the bgColor
so the text is readable.
This is minimal usage of the compoundVariants
, since it can take an array of possible combination but it will at least demonstrate how to use this field.
Now here's the DemoCtaButton
component in full with some annotations.
cta.tsx
import { cva, VariantProps } from 'class-variance-authority';
const ctaButtonVariants = cva([
], {
variants: {
textColor: {
grey: '!text-gray-600',
white: '!text-white',
black: '!text-black',
},
bgColor: {
white: 'bg-white',
black: 'bg-black',
orange: 'bg-orange-500',
slate: 'bg-slate-500',
purple: 'bg-purple-600',
green: 'bg-green-600',
red: 'bg-red-600',
blue: 'bg-blue-600'
},
fontWeight: {
normal: 'font-normal',
semibold: 'font-semibold',
bold: 'font-bold',
},
shadow: {
none: 'shadow-none',
inner: 'shadow-gray-300 shadow-inner',
xs: 'shadow-gray-800 shadow-xs',
sm: 'shadow-gray-800 shadow-sm',
md: 'shadow-gray-800 shadow-md',
lg: 'shadow-gray-800 shadow-lg',
xl: 'shadow-gray-800 shadow-xl',
'2xl': 'shadow-gray-800 shadow-2xl',
},
rounded: {
none: 'rounded-none',
xs: 'rounded',
sm: 'rounded-sm',
md: 'rounded-md',
lg: 'rounded-lg',
xl: 'rounded-xl',
'2xl': 'rounded-2xl',
'3xl': 'rounded-3xl',
full: 'rounded-full',
},
padding: {
none: 'px-0 py-0',
xs: 'px-2 py-1',
sm: 'px-3 py-2',
md: 'px-5 py-3',
lg: 'px-7 py-5',
xl: 'px-10 py-7',
'2xl': 'px-12 py-10',
'3xl': 'px-14 py-12',
},
},
defaultVariants: {
bgColor: 'blue',
fontWeight: 'semibold',
shadow: 'none',
rounded: 'md',
padding: 'md',
},
compoundVariants: [
{
bgColor: ['black', 'blue', 'green', 'orange', 'slate', 'red', 'purple'],
className: 'text-white',
},
{
bgColor: 'white',
className: 'text-inherit border border-gray-300',
},
],
});
export interface DemoCtaButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof ctaButtonVariants> {
text?: string;
}
export const DemoCtaButton: React.FC<DemoCtaButtonProps> = ({
text,
shadow,
padding,
textColor,
bgColor,
fontWeight,
rounded,
className,
style,
children,
...props
}) => {
return (
<button
className={ctaButtonVariants({
textColor,
bgColor,
fontWeight,
rounded,
shadow,
padding,
className,
})}
style={style}
{...props}
>
{text ? text : children}
</button>
);
};
Here's a quick breakdown of what's happening in the code with some extra examples variant props to better illustrate the usage of the config
section.
const ctaButtonVariants = cva([
], {
variants: {
bgColor: {
green: 'bg-green-600',
red: 'bg-red-600',
blue: 'bg-blue-600',
},
foo: {
bar: 'w-full',
bar2: 'w-[1/2]'
},
frequency: {
alpha: 'bg-blue-200',
beta: 'bg-orange-200'
omega: 'bg-green-200'
}
},
defaultVariants: {
bgColor: 'blue',
foo: 'bar2'
},
compoundVariants: [
{
bgColor: ['black', 'blue', 'green', 'orange', 'slate', 'red', 'purple'],
foo: ['bar'],
className: 'text-white',
},
{
foo: cn(isTruthy && 'bar2'),
frequency: ['omega'],
className: 'border bg-slate-200',
}
]
});
As you can see this becomes a powerful way to define and organize variants effectively.
Type-safe variants
By calling the cva
function with the base
and config
parameters, it will return a callable function.
We can now use the ctaButtonVariants
function to create a type-safe interface for our new variant props using the VariantProps<ctaButtonVariants>
type.
This is really a powerful aspect of cva
is its strong typing capabilities.
Since we're building a button component we can extend both the React.ButtonHTMLAttributes<HTMLButtonElement>
and the new VariantProps<ctaButtonVariants>
to create the needed interface for our component.
The only props
type that needs to be added to the interface is the text
field for the label of the button.
Defines the interface
export interface DemoCtaButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof ctaButtonVariants> {
text?: string;
}
Applying the variant classes
The last step is to actually call the ctaButtonVariants
function to build the Tailwind CSS class list which we'll use to set the className
attribute.
Notice that it's also passing the className
prop into the ctaButtonVariants
function to allow for overrides on a per component basis.
Also, using the style
attribute is key here, because we should account for any runtime styles that other non-variant props might set.
In my experience it's always best to let the style
attribute have the last say.
<button
className={ctaButtonVariants({
textColor,
bgColor,
fontWeight,
rounded,
shadow,
padding,
className,
})}
style={style}
{...props}
>
</button>
Making Tailwind CSS variants modular and reusable
Now that we've seen a working example, let's take a minute to consider how we can make things more organized and reusable.
The cva
library was inspired by other libraries like Vanilla Extract which is a great zero-runtime library. However, since we're using Tailwind CSS we should be able to write sharable and reusable code in a similar way.
Let's create an outline for a Card
component.
So to make this component more modular, we can extract out the common variants and create a commons
file.
Consider the following.
commons.tsx
export const backgroundVariants = cva({
});
export const colorVariants = cva({
});
export const boxVariants = cva({
});
And now we can outline the Card
component here and import the shared variants as follows.
card.tsx
import {backgroundVariants, colorVariants, boxVariants} from './commons';
import { cn } from './utils';
const cardVariants = cva({
});
export interface CardProps
extends React.HTMLAttributes<HTMLElement>,
VariantProps<typeof cardVariants>,
VariantProps<typeof backgroundVariants>,
VariantProps<typeof colorVariants>,
VariantProps<typeof boxVariants> {
}
export const Card: React.FC<CardProps> = ({
className,
style,
children,
...props
}) => {
return (
<div
className={cn(
backgroundVariants({
}),
colorVariants({
}),
boxVariants({
}),
cardVariants({
className,
})
)}
style={style}
{...props}
>
{children}
</div>
);
}
Now we can reuse variant definitions plus making it easier to manage common variant styles.
Component variants with complex logic
If there are cases where you need to tailor variants based on some conditions. Let's say for example you have some brand or seasonal promotional requirements to temporarily modify a components theme.
class variance authority
makes it easy to apply just about any type of logic you'll need to further refine variant styles.
Variants applied using conditional logic
Rather than just using static Tailwind CSS classes, we can also conditionally set values using the cn
function.
card example with branding
import { cn } from './utils';
import { cva } from 'class-variance-authority';
const brand = {
darkMode: true,
flags: {
holiday2023: true
},
colors: {
bg: 'bg-red-600',
color: 'text-white',
border: 'border-slate-200',
darkMode: {
bg: 'bg-slate-500',
color: 'text-white',
border: 'border-slate-400',
}
}
};
const cardVariants = cva([
cn(brand?.flags?.holiday2023 && 'bg-red-600')
], {
variants: {
border: {
brand: cn(brand?.colors?.border && brand.colors.border),
dark: cn(brand?.darkMode && brand.colors.darkMode.border)
},
bgColor: {
brand: cn(brand?.colors?.bg && brand.colors.bg),
dark: cn(brand?.darkMode && brand.colors.darkMode.bg)
}
},
defaultVariants: {},
compoundVariants: []
});
As you can see, any variant can use the cn
utility, including defaultVariants
and compoundVariants
.
Here's a quick reference chart for some of the other possible ways to apply conditional classes inside the cva
function.
Each variant can be a clsx
class value with any number of conditions.
The cn
utility function could use the following possible class value logic.
reference for cn utility
cn('border-2', true && 'p-3', 'flex');
cn({ 'border-2':true, 'p-3':false, 'flex':isTrue() });
cn({ 'border-2':true }, { 'p-3':false }, null, { '--prime-theme-bg':'foo' });
cn(['border-2', 0, false, 'p-3']);
cn(['border-2'], ['', 0, false, 'p-3'], [['flex', [['bg-slate-300'], 'shadow']]]);
cn('border-2', [1 && 'p-3', { 'flex':false, 'justify-center':null }, ['bg-slate-300', ['m-4']]], 'shadow');
Conclusion
At this point we've seen some examples of how to use CVA for component variant composition using Tailwind CSS.
With so many technologies out there, this is definitely a combination I use frequently to quickly build quality components with Tailwind CSS.
Using Tailwind CSS and CVA together allows for more focus on building the component business logic rather than maintaining complex variant logic inside each component.
I hope this article has helped to shed some light on these two technologies.