class-variance-authority
cva turns a variant config object into a typed function that returns a class string. You give it a base class list plus a map of variants (size, intent, disabled), optional defaults, and compound rules for specific combinations; you get back a function whose argument type TypeScript infers from that same config, so the props of your Button and the classes it emits can never drift apart. At runtime it is string picking and joining on top of clsx, about 0.6 KB gzipped, with no framework binding. shadcn/ui put a cva call at the top of nearly every component it ships, which is why the download count is far larger than the star count suggests.
The best small answer for typed Tailwind variants, and effectively unavoidable if you touch shadcn/ui. Budget for tailwind-merge next to it, and accept that the published package has not moved since November 2024.
Use it if
- You maintain Tailwind design-system components and want size, intent, and state variants declared once instead of scattered through template ternaries
- You use shadcn/ui or copied its component files: they already import cva, so removing it means rewriting every component by hand
- You want the prop type generated from the class config via VariantProps<typeof buttonVariants> rather than maintaining a parallel union type
- You need variant logic that works outside React too, because the output is just a string usable in Vue, Svelte, Astro, or a server-rendered template
- You expect Tailwind conflict resolution: cva does not do it. A component with base 'p-4' that receives className='p-8' emits both classes and the winner is decided by CSS source order, so in practice you install tailwind-merge as well, which is a bigger dependency than cva itself
- Your components have parts: a Card with header, body, and footer needs a separate cva() call per part because there are no slots. tailwind-variants has slots and variant composition built in for exactly that shape
- You want an actively released package: 0.7.1 shipped in November 2024 and nothing has been published since, while the 1.0 rewrite still sits on the canary tag. Commits land in the repo, but a fix you need may not reach npm for a long time
- You only have two or three conditional classes: clsx, which cva already depends on, or a plain ternary does the same job without a config object to maintain
- Your team relies on Tailwind IntelliSense: class autocomplete stops working inside cva() until every developer adds a classRegex entry to their editor settings
Setup reality
npm install class-variance-authority pulls exactly one dependency (clsx) and ships its own types, so the install itself is a non-event. Everything annoying comes after. Tailwind IntelliSense will not complete or lint class names inside cva() or cx() until you add tailwindCSS.experimental.classRegex entries in your editor settings, and every teammate has to do it. Tailwind's content globs must cover the files where variants are declared, otherwise classes get purged from the build and only in production. Most teams then install tailwind-merge and wrap cx plus twMerge into a local cn() helper, because cva alone will happily hand you 'p-4 p-8'. There is no Babel plugin, no config file, and no runtime cost beyond string joining.
Patterns
Define a variant functiondefine-variants
import { cva } from 'class-variance-authority';
const button = cva('inline-flex items-center rounded font-medium', {
variants: {
intent: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'bg-transparent text-blue-600 hover:bg-blue-50',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
},
},
});
button({ intent: 'primary', size: 'sm' });
// 'inline-flex items-center rounded font-medium bg-blue-600 text-white hover:bg-blue-700 h-8 px-3 text-sm'The first argument is the base classes and may be omitted. Order in the output follows base, then variants in declaration order, which matters only if two variants set the same Tailwind property.
Set defaults so callers can pass nothingdefault-variants
const button = cva('rounded', {
variants: {
intent: { primary: 'bg-blue-600', ghost: 'bg-transparent' },
size: { sm: 'h-8 px-3', md: 'h-10 px-4' },
},
defaultVariants: { intent: 'primary', size: 'md' },
});
button(); // 'rounded bg-blue-600 h-10 px-4'defaultVariants apply when the key is missing or undefined. Passing an explicit undefined still falls back to the default; only null skips the variant entirely.
Add classes only for a combinationcompound-variants
const button = cva('rounded', {
variants: {
intent: { primary: 'bg-blue-600', danger: 'bg-red-600' },
size: { sm: 'h-8 px-3', md: 'h-10 px-4' },
},
compoundVariants: [
{ intent: 'danger', size: 'sm', class: 'font-bold uppercase tracking-wide' },
],
defaultVariants: { intent: 'primary', size: 'md' },
});A compound entry fires only when every listed variant matches. Its class is appended after the regular variant classes, so it wins ties in source order but still loses to a later rule in your CSS.
Match several values in one compound rulecompound-variant-multi-match
const badge = cva('inline-block rounded px-2', {
variants: {
tone: { info: 'bg-sky-100', warn: 'bg-amber-100', error: 'bg-red-100' },
outline: { true: 'border', false: '' },
},
compoundVariants: [
{ tone: ['warn', 'error'], outline: true, class: 'border-current' },
],
});Arrays inside compoundVariants mean any-of. You cannot express not-equal or ranges; for that, compute the variant value before calling the function.
Use a boolean variantboolean-variant
const input = cva('block w-full rounded border px-3', {
variants: {
invalid: {
true: 'border-red-500 focus:ring-red-500',
false: 'border-gray-300 focus:ring-blue-500',
},
},
defaultVariants: { invalid: false },
});
input({ invalid: true });Keys are the strings 'true' and 'false' in the config, but the generated prop type is a real boolean. Declare the 'false' branch too, or the default state gets no classes at all.
Derive prop types from the configtyped-props
import { cva, type VariantProps } from 'class-variance-authority';
const button = cva('rounded', {
variants: {
intent: { primary: 'bg-blue-600', ghost: 'bg-transparent' },
size: { sm: 'h-8', md: 'h-10' },
},
});
type ButtonVariants = VariantProps<typeof button>;
// { intent?: 'primary' | 'ghost' | null; size?: 'sm' | 'md' | null }VariantProps strips class and className from the inferred argument, so you can spread it into your own props type without a duplicate class prop.
Wire it into a React componentreact-component
import { cva, type VariantProps } from 'class-variance-authority';
import type { ButtonHTMLAttributes } from 'react';
const buttonVariants = cva('inline-flex items-center rounded', {
variants: {
intent: { primary: 'bg-blue-600 text-white', ghost: 'bg-transparent' },
size: { sm: 'h-8 px-3', md: 'h-10 px-4' },
},
defaultVariants: { intent: 'primary', size: 'md' },
});
type Props = ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
export function Button({ intent, size, className, ...rest }: Props) {
return (
<button className={buttonVariants({ intent, size, className })} {...rest} />
);
}Passing className through the variant call appends it last. Export buttonVariants separately so other components can reuse the same classes on an anchor or a Link.
Resolve Tailwind conflicts with a cn helpermerge-conflicting-classes
import { cx } from 'class-variance-authority';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: Parameters<typeof cx>) {
return twMerge(cx(inputs));
}
// then, in the component
className={cn(buttonVariants({ intent, size }), className)}This is the single most important thing cva does not do. Without twMerge, a caller passing 'p-8' to a component whose base is 'p-4' gets both classes and unpredictable spacing.
Turn a variant off with nullunset-variant
const button = cva('rounded', {
variants: { size: { sm: 'h-8 px-3', md: 'h-10 px-4' } },
defaultVariants: { size: 'md' },
});
button({ size: null }); // 'rounded' (no size classes at all)null is the only way to opt out of a defaultVariant. undefined falls back to the default, which surprises people passing an optional prop straight through.
Join conditional classes with cxcx-classnames
import { cx } from 'class-variance-authority';
cx('base', isActive && 'bg-blue-50', ['px-2', { 'sr-only': hidden }]);cx is a re-export of clsx, not a separate implementation, so anything clsx accepts works here and there is no extra bytes cost if you already ship cva.
Restore Tailwind autocomplete inside cvaeditor-intellisense
// .vscode/settings.json
{
"tailwindCSS.experimental.classRegex": [
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
["cx\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
]
}Without this, class names inside cva() get no completion, no hover preview, and no lint warnings. It is per-editor config, so it needs to be committed to the repo to help the whole team.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| tailwind-variants | npm | You need slots for multi-part components, variant composition, and class conflict resolution in one package instead of assembling them |
| tailwind-merge | npm | You only need the conflicting-class resolution part and are happy expressing variants with a plain object lookup |
| clsx | npm | Your components toggle a handful of classes and never grow into a variant matrix |