If you've built a component library with Tailwind CSS, you've probably hit the same wall: a Button component that starts clean and, six months later, is a jungle of ternaries and template strings trying to handle variant, size, and state combinations. Three libraries have emerged to solve this specific pain point:
They overlap in purpose but solve slightly different problems, and understanding where each one fits will save you from reaching for the wrong tool.
The Problem at Hand
A typical component without any CVA library might look like this:
function Button({ variant, size, disabled, className }: ButtonProps) {
return (
<button
className={`
rounded font-medium transition-colors
${variant === 'primary' ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}
${variant === 'secondary' ? 'bg-gray-200 text-gray-900 hover:bg-gray-300' : ''}
${size === 'sm' ? 'px-2 py-1 text-sm' : ''}
${size === 'lg' ? 'px-6 py-3 text-lg' : ''}
${disabled ? 'opacity-50 cursor-not-allowed' : ''}
${className || ''}
`}
/>
)
}It works, but it's unreadable, hard to test, and every new variant adds another ternary. This is exactly the gap these three libraries fill — just at different layers.
Class Variance Authority (CVA)
CVA gives a declarative API for defining variants and their combinations. Define a base, a set of variants, defaultVariants and optional compoundVariants.
import { cva, type VariantProps } from 'class-variance-authority'
const button = cva('rounded font-medium transition-colors', {
variants: {
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300',
},
size: {
sm: 'px-2 py-1 text-sm',
lg: 'px-6 py-3 text-lg',
},
},
compoundVariants: [
{ variant: 'primary', size: 'lg', class: 'shadow-md' },
],
defaultVariants: {
variant: 'primary',
size: 'sm',
},
})
type ButtonProps = VariantProps<typeof button>
function Button({ variant, size, className }: ButtonProps & { className?: string }) {
return <button className={button({ variant, size, className })} />
}What makes CVA useful in practice:
It's framework-agnostic. The
button()function just returns a string. It works the same no matter the framework used, be it React, Vue, Angular or a plain HTML template. It's a real advantage when you're maintaining a design system across frameworks.VariantPropsgives you free TypeScript types. Your component's prop types are derived directly from the variant definition, so they can never drift out of sync.It's intentionally unopinionated about the rest of your component. CVA only builds class strings — it doesn't touch merging or conflict resolution, which is where
tailwind-mergecomes in.
CVA's limitation is that it's single-component-scoped by design. If you need slot-based variants (styling multiple internal parts of one component — think a Card with root, header, and body that all need their own variant logic), you end up composing multiple cva() calls manually.
Tailwind Variants
Tailwind Variants (tv) is best thought of as "CVA + slots + tailwind-merge, built in." It was created specifically to address CVA's gaps for compound components.
import { tv } from 'tailwind-variants'
const card = tv({
slots: {
root: 'rounded-lg border',
header: 'border-b px-4 py-2 font-semibold',
body: 'p-4',
},
variants: {
color: {
primary: {
root: 'border-blue-200',
header: 'bg-blue-50 text-blue-900',
},
danger: {
root: 'border-red-200',
header: 'bg-red-50 text-red-900',
},
},
},
});
const { root, header, body } = card({ color: 'primary' });Each slot gets its own class string, all derived from a single variant definition — no manually wiring together three separate cva() calls and passing color props down to each.
The other meaningful difference: Tailwind Variants merges conflicting classes automatically (it bundles its own conflict resolution, conceptually similar to tailwind-merge), so tv({ base: 'px-2', })({ className: 'px-4' }) resolves sensibly instead of emitting both classes and letting Tailwind's CSS cascade decide by source order, which is a common source of "why isn't my override working" bugs.
The trade-off is that Tailwind Variants is more opinionated and slightly heavier, and being younger than CVA, its ecosystem examples and community Q&A are thinner. For a simple single-element component, it's arguably more machinery than you need. That's CVA's sweet spot.
tailwind-merge
"tailwind-merge" solves a completely different problem: class conflict resolution, not variant authoring. Tailwind classes don't override each other based on specificity like normal CSS — they override based on source order in the generated stylesheet, which is often not the order they appear in your markup. Concatenating px-2 and px-4 doesn't give you px-4; it gives you whichever one Tailwind happened to generate last, which is fragile and easy to get wrong when className props come from a consumer.
import { twMerge } from 'tailwind-merge'
twMerge('px-2 py-1 bg-red-500', 'px-4 bg-blue-500')
// → 'py-1 px-4 bg-blue-500'"tailwind-merge" understands Tailwind's own class groups (padding, background color, etc.) and correctly resolves which class in a given group should "win," regardless of order. It's small, does one job, and is often paired with clsx for a common cn() utility:
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}This cn() pattern is everywhere in the shadcn/ui ecosystem, as well as in major UI libraries such as HeroUI, for a reason: it lets a component define sensible default classes while still letting a consumer override them via className, without the two fighting over CSS specificity.
import { cn } from '@/lib/utils'
export function ({ className, children }: { className?: string, children?: ReactNode }) {
return <div className={cn('bg-slate-100', className)}></div>
}import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}How they actually fit together
These aren't really competitors — CVA and Tailwind Variants operate at the "variant authoring" layer, while "tailwind-merge" operates at the "conflict resolution" layer underneath them. A common, pragmatic stack looks like:
Need | Reach for |
|---|---|
Single component, few variants, framework-agnostic | CVA |
Compound component with multiple styled slots | Tailwind Variants |
Merging a consumer's | tailwind-merge (often via a |
CVA output that also needs to merge overrides | CVA + tailwind-merge together |
In fact, CVA's own docs recommend pairing it with tailwind-merge for exactly this reason — CVA doesn't dedupe conflicting classes on its own, so if a consumer passes className="px-8" into a button that already resolves to px-2 internally, you'll get both in the DOM unless something merges them.
