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:
| Layer | Variant OK? | Example |
|---|---|---|
| Primitive | Yes: visual/behavioral axes only | variant, size, tone |
| Pattern | Sparingly, choreography, not product nouns | FormField density |
| Domain | Prefer wrappers, not new primitive axes | ApproveInvoiceButton |
Decision criteria I use in review:
- Can a second product reuse this axis without renaming? → primitive variant.
- Does the name mention a workflow or entity (
approve,invoice,billing)? → domain wrapper that picks a primitive variant. - 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.
tscompoundVariants: [ { 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.