cva component variants

why

a component boundary that leaks styling details is a contract leak: callers depend on the child's internal class structure, and one visual change fans out into every call site. semantic variants keep coupling one-way — the parent states intent, the component decides appearance.

rule

an authored component owns its styling and exposes semantic CVA variants. upper components choose intent (tone, size, state); they never pass styling override strings through the boundary. the child owns how its visual data renders; the parent owns only whether and where the child is composed.

model a bounded visual vocabulary

a CVA declaration is a component contract and a small visual data model. each variant axis answers one semantic question callers already share: tone = intent, size = density, state = interaction mode. the component owns the mapping from intent to classes, tokens, responsive behavior, and compound intersections.

do not add an axis for a one-off caller's class. ask whether the caller expresses a reusable concept: if not, keep the composition local; if yes, add one named option with the smallest stable meaning.

component extraction and styling ownership

a parent repeatedly changing an imported component's className means the child is missing a semantic variant — or the two uses are different components. do not make every caller a partial owner of child CSS: extract the stable visual responsibility into the child with a bounded typed variant API, making visual consistency a data-design property rather than a review habit.

observed pressureselected direction
callers need the same semantic appearanceadd one component-local CVA variant; update all callers to it.
callers need the same markup, interaction, and visual contractextract one component with owned variants.
callers need different domain vocabulary or behaviorkeep distinct components; no generic styling switchboard.
a parent needs placement, grid, order, or gapown that layout on the parent wrapper, not by overriding the child.
a library-derived primitive explicitly supports classNameuse its documented escape hatch only at its owning primitive boundary; never forward it through authored product components.

variants are analogous to closed fields in a data model: name, allowed values, default, one owner. arbitrary utility strings are untyped write access — they make equivalent states look different, block a global visual correction, and hide the supported appearance set from search and type checking.

extraction test

  1. start with one component-local presentation and a clear responsibility.
  2. compare callers: do two or more need the same semantic distinction, not merely the same temporary spacing adjustment?
  3. if yes, declare the variant close to the component; infer its prop union from the declaration.
  4. keep compound variants for genuine intersections (a destructive, disabled control); not as a hidden second styling API.
  5. extract a separate component only after markup and behavior are both repeatedly identical.

failure modes prevented

shortcutcostconsequence
passing arbitrary className through an authored boundaryevery consumer co-owns the component's visual contract.a token change (e.g., color role) cannot update all instances; corrections are manual copy-paste.
encoding domain status as utility classesthe UI vocabulary is duplicated, unsearchable by intent.two components show the same status in different colors; searching "success" doesn't find both.
adding a variant for every local exceptionthe component becomes an undocumented styling switchboard.maintainers can't understand supported appearances; new callers guess or invent variants.
copying markup because two views look similar todaylater behavior and a11y fixes diverge silently.an a11y fix in one copy misses the other; behavior changes affect only one view.

do not

  • export className as a generic escape hatch for authored components.
  • forward a parent's className into an authored child.
  • encode business state in a visual class string.
  • duplicate a component only to obtain a different utility-class combination when a semantic variant owns the distinction.

verification

  • read the variant declaration as an API: every option has a named consumer meaning, every default is intentional, no caller knows the internal class composition.
  • search callers for className passed to authored components; replace each with a component variant, parent wrapper layout, or a distinct component.
  • render the supported variant matrix at the component boundary when a change affects contrast, density, disabled behavior, or responsive layout.

good and bad

bad

// Dashboard.tsx
<StatusBadge className="bg-green-500 text-white px-3 py-1" />
// ProcessList.tsx
<StatusBadge className="bg-emerald-500 text-slate-900 px-2 py-1 rounded-md" />
// AdminPanel.tsx — same status, trying to match the app's accent color
<StatusBadge className="bg-blue-600 text-white px-3 py-1" />

each caller owns styling independently: the same "success" status renders green in one place, emerald in another, blue in admin. a design audit ("success is always green-500") means finding and editing three call sites; new components copy the pattern and invent their own palette. the badge has no consistent appearance.

good

// StatusBadge.tsx
const badge = cva("inline-flex items-center font-medium", {
variants: {
tone: {
positive: "bg-success text-white",
negative: "bg-error text-white",
neutral: "bg-surface text-foreground",
},
size: {
compact: "px-2 py-0.5 text-sm",
normal: "px-3 py-1 text-base",
},
},
});
export function StatusBadge({ tone, size }: Props) {
return <div className={badge({ tone, size })}>...</div>;
}
// all callers use the component's variants
<StatusBadge tone="positive" size="compact" />
<StatusBadge tone="positive" size="normal" />

the component maps named visual intent to its own CVA declaration. a --success token update propagates everywhere; new callers choose from documented options instead of inventing variants. consistent appearance everywhere.

search pages

go to any page