Skip to content

Class Variance Authority: Type-Safe Variants

Put typed variants on primitives with CVA, and stop variant explosion before product nouns leak into the design-system API.

4 min read
CVA
Class Variance Authority
Tailwind
Design System
TypeScript
React

Wave 5 closed with recovery: error boundaries as blast-radius tools. The UI system thread is not done. July's component library post left a hanging failure mode: variant explosion on primitives: and Class Variance Authority (CVA) is how I close that gap without stringly-typed className soup.

CVA is not a component library. It is a typed recipe from variant props to class strings. The craft is deciding which recipes belong on primitives versus domain wrappers.

What CVA actually buys

Without a variant layer, primitives drift into:

tsx
// The anti-pattern, illustrative <button className={`rounded px-3 py-2 ${ primary ? 'bg-blue-600 text-white' : 'bg-transparent' } ${size === 'sm' ? 'text-sm' : 'text-base'} ${className ?? ''}`} />

Conflicts pile up (px-3 vs consumer px-4), TypeScript does not know legal combinations, and every new look becomes another boolean. CVA turns that into an explicit map with typed props and defaults.

tsx
// packages/ui/src/button.tsx, illustrative primitive + CVA import { cva, type VariantProps } from 'class-variance-authority'; import type { ButtonHTMLAttributes } from 'react'; const buttonVariants = cva( 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50', { variants: { variant: { primary: 'bg-blue-600 text-white hover:bg-blue-700', secondary: 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200', ghost: 'bg-transparent hover:bg-zinc-100', }, size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4 text-sm', lg: 'h-11 px-6 text-base', }, }, defaultVariants: { variant: 'primary', size: 'md', }, }, ); type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>; export function Button({ className, variant, size, type = 'button', ...props }: ButtonProps) { return ( <button type={type} className={buttonVariants({ variant, size, className })} {...props} /> ); }

VariantProps keeps the public API honest: consumers get autocomplete for variant / size, not tribal knowledge of which class strings "feel primary."

Where variants belong (and where they do not)

Reuse the three altitudes from the primitives-to-domain post:

LayerVariant OK?Example
PrimitiveYes: visual/behavioral axes onlyvariant, size, tone
PatternSparingly, choreography, not product nounsFormField density
DomainPrefer wrappers, not new primitive axesApproveInvoiceButton

Decision criteria I use in review:

  1. Can a second product reuse this axis without renaming? → primitive variant.
  2. Does the name mention a workflow or entity (approve, invoice, billing)? → domain wrapper that picks a primitive variant.
  3. Would adding this axis multiply combinations by more than ~2× without a design-token story? → stop; compose instead.
tsx
// apps/web/src/features/invoices/ApproveInvoiceButton.tsx, domain wrapper import { Button } from '@acme/ui'; export function ApproveInvoiceButton( props: Omit<React.ComponentProps<typeof Button>, 'variant'>, ) { return ( <Button variant="primary" {...props} /> ); }

Do not add approveInvoice to buttonVariants. That is product vocabulary leaking into @acme/ui.

Compound variants: justify them

CVA's compoundVariants exist for real cross-axis rules, not for encoding every design whim.

ts
compoundVariants: [ { variant: 'ghost', size: 'sm', class: 'px-2', // ghost+sm needs tighter padding than the size default }, ],

If you need a compound rule, write the reason in a comment. If you cannot state the reason in one line, you probably want a new pattern component, not another compound row.

Defaults and the public contract

defaultVariants are part of the public API. Changing variant default from primary to secondary is a visual breaking change even if TypeScript stays green. Treat defaults like package semver: bump, changelog, or keep the old default and add an explicit new axis.

I also keep one escape hatch: className last in the CVA call so consumers can nudge without forking the primitive. That hatch is useless without merge discipline from the tailwind-merge post. Without it, consumer padding "wins" by accident of string order, not by policy.

Failure modes

Variant explosion. Twenty button looks that encode workflows. Fix: domain wrappers + a small primitive matrix (usually ≤3×3).

Boolean props beside variants. primary + variant="secondary" fights itself. Pick one API shape.

Passing raw Tailwind as a fake fourth axis. className="bg-red-600" that silently overrides variant without tailwind-merge produces unreproducible UI. Treat className as escape hatch, not the main API.

Variants that restate tokens. If every "primary" hard-codes hex that already lives in CSS variables from May's token post, you duplicated the contract. Prefer token-backed classes (bg-primary, text-primary-fg).

Invented adoption metrics. I will not claim "N% fewer style bugs" without telemetry. The qualitative win is a typed, reviewable matrix.

Continuity

Keep product nouns out of the primitive package. Isolate blast radius at the route and widget level. CVA isolates visual axes so primitives stay small and typed. clsx + tailwind-merge is the composition discipline that makes className escape hatches safe instead of chaotic.

Takeaway

CVA pays off when it turns a primitive's looks into a small typed matrix - and loses it the moment product workflows become variant names.