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 pressure | selected direction |
|---|---|
| callers need the same semantic appearance | add one component-local CVA variant; update all callers to it. |
| callers need the same markup, interaction, and visual contract | extract one component with owned variants. |
| callers need different domain vocabulary or behavior | keep distinct components; no generic styling switchboard. |
| a parent needs placement, grid, order, or gap | own that layout on the parent wrapper, not by overriding the child. |
a library-derived primitive explicitly supports className | use 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
- start with one component-local presentation and a clear responsibility.
- compare callers: do two or more need the same semantic distinction, not merely the same temporary spacing adjustment?
- if yes, declare the variant close to the component; infer its prop union from the declaration.
- keep compound variants for genuine intersections (a destructive, disabled control); not as a hidden second styling API.
- extract a separate component only after markup and behavior are both repeatedly identical.
failure modes prevented
| shortcut | cost | consequence |
|---|---|---|
passing arbitrary className through an authored boundary | every 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 classes | the 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 exception | the 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 today | later behavior and a11y fixes diverge silently. | an a11y fix in one copy misses the other; behavior changes affect only one view. |
do not
- export
classNameas a generic escape hatch for authored components. - forward a parent's
classNameinto 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
classNamepassed 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.tsxconst 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.