You have a React component that needs to show a primary button, secondary, error, large, small, with an icon. You start concatenating className with ternary operators, conditional objects, template strings. After a few variants the code becomes unreadable, errors hide in details, and every change requires re-reading everything. Exactly: the problem isn't Tailwind, it's how you handle variants.
We, at Meteora Web, have been working with React and Tailwind for years. We've seen teams struggle with mile-long inline classes. Then we discovered CVA — Class Variance Authority — and managing variants became a whole new story. In this guide we'll see how to handle dynamic className in React with Tailwind, and why CVA is the right tool to keep your sanity.
Why Are Dynamic ClassNames in React with Tailwind a Problem?
Tailwind works with utility classes: bg-blue-500 text-white px-4 py-2 rounded. In React, when a component needs to change appearance based on props or state, you have to generate different class strings. The simplest way is to write directly in JSX:
function Button({ variant, size, children }) {
let className = 'font-semibold rounded focus:outline-none';
if (variant === 'primary') className += ' bg-blue-600 text-white hover:bg-blue-700';
if (variant === 'secondary') className += ' bg-gray-200 text-gray-800 hover:bg-gray-300';
if (size === 'sm') className += ' px-2 py-1 text-sm';
if (size === 'lg') className += ' px-6 py-3 text-lg';
return <button className={className}>{children}</button>;
}The problem? This approach does not scale. Add a 'danger' variant, a disabled state, a loading spinner — code gets longer, conditions duplicate, and readability plummets. It's easy to miss a case or accidentally override a class. In real-world projects, we've seen components with over 50 lines of class logic. Not maintainable.
Sponsored Protocol
Common mistakes with dynamic className
- Accidental override: string concatenation can lose base classes if an if is not executed.
- Inconsistencies: changing a variant requires updating multiple points.
- Reusability difficulty: extracting logic into a helper hook becomes messy quickly.
The solution is to centralize variants in one place, with a clear and predictable syntax. This is where CVA comes in.
What is CVA (Class Variance Authority) and How Does It Work?
CVA is a lightweight library (~1KB) that lets you define component variants declaratively. Instead of writing ifs, you use an object that maps each variant to a set of classes. The result is a function that, given the variant values, returns the correct className string. It works perfectly with Tailwind because classes are strings.
Sponsored Protocol
Simple installation:
npm install class-variance-authorityOr:
yarn add class-variance-authorityNow let's define the same button with CVA:
import { cva } from 'class-variance-authority';
const buttonVariants = cva(
'font-semibold rounded focus:outline-none', // base classes
{
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
size: {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
function Button({ variant, size, className, children }) {
return (
<button className={buttonVariants({ variant, size, className })}>
{children}
</button>
);
}That's it. Now if you want a primary large button, call <Button variant='primary' size='lg'>Click</Button>. CVA handles combining base classes with selected variant classes, and also manages extra classes passed via className. No ternary, no ifs, no concatenation errors.
How CVA handles extra classes
The last parameter of buttonVariants is className: CVA automatically merges it at the end, allowing the consumer to add overrides. Essential for flexibility.
Sponsored Protocol
How to Use CVA with Tailwind for Complex Components?
Now that we have the basics, let's extend to components with multiple variants and states. Take a badge with color and shape variants, plus a disabled state.
const badgeVariants = cva(
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
{
variants: {
color: {
gray: 'bg-gray-100 text-gray-800',
red: 'bg-red-100 text-red-800',
yellow: 'bg-yellow-100 text-yellow-800',
green: 'bg-green-100 text-green-800',
blue: 'bg-blue-100 text-blue-800',
},
shape: {
pill: 'rounded-full',
square: 'rounded-md',
},
disabled: {
true: 'opacity-50 cursor-not-allowed',
},
},
defaultVariants: {
color: 'gray',
shape: 'pill',
},
}
);
function Badge({ color, shape, disabled, className, children }) {
return (
<span className={badgeVariants({ color, shape, disabled, className })}>
{children}
</span>
);
}Now we have a badge that supports five colors, two shapes, and a disabled state, with just a few lines of definition. Logic stays centralized and clear.
Sponsored Protocol
Handling boolean variants and compound variants
CVA also supports compound variants (specific combinations). For example, if we want a button with variant='danger' and size='lg' to have an extra border:
const buttonVariants = cva(
'...',
{
variants: { ... },
compoundVariants: [
{
variant: 'danger',
size: 'lg',
className: 'border-2 border-red-800',
},
],
defaultVariants: { ... },
}
);Useful for specific cases without multiplying variants.
What Alternative to CVA? Comparison with clsx, tailwind-merge, and twMerge
Before CVA, many used clsx to merge conditional classes, or tailwind-merge to resolve utility conflicts. CVA doesn't replace these tools entirely, but integrates with them: often you use cva together with twMerge to ensure that classes passed by the consumer correctly override those defined. Here's a common pattern:
import { cva } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
const buttonVariants = cva('...', { ... });
function Button({ variant, size, className, ...props }) {
return (
<button
className={twMerge(buttonVariants({ variant, size }), className)}
{...props}
/>
);
}With twMerge we resolve conflicts like bg-blue-600 overridden by bg-red-600 passed externally. CVA takes care of variant logic, twMerge ensures correct class ordering.
Sponsored Protocol
A real-world example from our work
In an e-commerce project for a Sicilian client, we had a product card with over 10 variants (layout, theme color, availability status, discount, promotion badge). Before CVA, the component had 80 lines of ternaries. After CVA, 20 lines of definition and 5 of rendering. Maintenance and testing became trivial.
What to do now
- Install CVA in your React project:
npm install class-variance-authority. - Rewrite a simple component (e.g., Button) with CVA following the pattern above. Use
defaultVariantsto avoid undefined values. - Integrate twMerge if you want to allow external overrides without conflicts:
npm install tailwind-merge. - Explore compoundVariants to handle special combinations without repeating code.
- Check the official documentation: cva.style/docs for more details.
And if you're starting with Tailwind and React, take a look at our deep dive on Tailwind CSS for modern UI — the foundation for understanding why we use this stack in real projects.
Dynamic classNames don't have to be a nightmare. With CVA, every component becomes predictable, testable, and easy to extend. And as we always say: if the code isn't clear, the problem isn't the dev — it's the tool you're using.