CVA Is Not a Design System: What Type-Safe Variants Actually Solve
Class Variance Authority gives React components a typed vocabulary for visual variants. It does not replace component state, accessibility, or design-token governance.
- Published
- October 26, 2025
- Updated
- Updated July 16, 2026
- Author
- Daniel Mark
- Reading time
- 11 min read
A button can have one implementation and still have a bad API.
It can accept five booleans, three vaguely named appearance props, an unrestricted className, and a loading state that looks disabled without behaving like a disabled button. Consolidating that code into one file does not make it a component system.
Class Variance Authority, usually shortened to CVA, helps with a narrower problem: defining a finite, typed vocabulary for the classes a component can produce.
That boundary is useful. It is also frequently overstated.
CVA does not create design tokens. It does not guarantee accessible contrast. It does not turn component behavior into a state machine. It does not make every combination of props valid, and it does not eliminate runtime class composition.
What it can do is replace scattered styling decisions with an explicit component contract.
That is enough to make it valuable.
The problem is not string concatenation
A typical button often begins innocently:
The problem is not that this component performs a few conditional checks. That cost is unlikely to matter beside rendering, network activity, image delivery, or the rest of the application.
The problem is that the component API does not explain its own rules.
Can a danger button be muted? Does loading imply disabled? Is small={false} equivalent to the default size? Can a consumer pass className="bg-green-500" and silently replace the selected variant? Does pointer-events-none make the control inaccessible through a keyboard?
The styling logic, behavioral state, accessibility semantics, and extension points have been mixed together.
That ambiguity becomes expensive when the component is reused across products or maintained by several developers. Each consumer has to rediscover what the props mean and which combinations happen to work.
The boundary CVA actually owns
CVA provides a structured way to map named variant values to class names.
A basic definition might look like this:
This establishes three visual axes:
intentsizewidth
The available values are derived from the variant definition. A consumer cannot pass intent="important" or size="huge" without receiving a TypeScript error.
That is the main guarantee.
CVA centralizes class selection and gives TypeScript enough information to describe the accepted values. It also gives editors a better autocomplete surface than a collection of loosely related booleans.
It does not yet define the complete button.
Styling contracts and component behavior are different concerns
The React component still owns its HTML behavior:
CVA decides which visual classes correspond to intent, size, and width.
The component decides that:
- A loading button is disabled.
- The native
disabledattribute is applied. aria-busyreflects the current operation.- The spinner is decorative.
- The default button type is
"button"rather than the form-submitting browser default. - Children remain available as the accessible name.
This separation matters.
A disabled-looking class does not disable an element. An opacity utility does not communicate state to assistive technology. pointer-events-none does not prevent keyboard activation.
CVA can help style an accessible component. It cannot make an inaccessible component accessible.
Variant values are typed; all combinations are not
One of the strongest claims made about CVA is that it makes invalid states impossible.
That is only partly true.
Given the earlier definition, TypeScript rejects an unknown value:
It does not automatically reject a valid value used in an invalid combination.
Imagine that the design system allows:
- Primary, secondary, and danger intents
- Solid, outline, and ghost appearances
- Danger buttons in solid or outline form
- No danger ghost button
A normal CVA definition can still describe all three appearance values:
The following call remains type-correct:
Both values exist. CVA does not know that the combination violates a product rule.
That restriction belongs in the component’s TypeScript contract:
Now the prohibited combination fails during typechecking:
CVA provides the raw variant types. TypeScript unions define which combinations the component permits.
That distinction is important because a class-composition helper should not be credited with guarantees supplied by a separate type contract.
Compound variants describe styling relationships
CVA supports compound variants for classes that apply only when several conditions match.
For example, an outlined danger button may need different border and focus styles from other outlined buttons:
This is preferable to duplicating conditional class expressions throughout the React component.
It also makes the styling relationship inspectable in one place. A reviewer can see that danger plus outline has a specific treatment.
Compound variants still do not prohibit combinations. They apply additional classes when conditions match. Validation remains a separate concern.
That makes them a styling tool, not a state-machine implementation.
Typed configuration is not the enemy
The original version of this article treated configuration objects as a weaker alternative to CVA.
That comparison was too broad.
A typed configuration object can be exactly the right abstraction when a state controls content rather than classes:
That object is fully checked against the Status union. TypeScript can report missing keys, unknown statuses, or invalid metadata.
CVA can then handle only the styling layer:
The final component composes both contracts:
The metadata record owns labels and icons. CVA owns variant classes. Neither abstraction has to impersonate the other.
Good component architecture is not about choosing one helper for everything. It is about assigning each decision to the narrowest reasonable boundary.
Design tokens should carry the visual meaning
CVA becomes more useful when its classes refer to semantic design tokens rather than raw colors.
This describes appearance:
This describes intent:
The second form allows the token system to decide what “primary action” means in each theme or brand.
CVA does not create that token system. It merely consumes the classes exposed by it.
The tokens still need to define:
- Default and interactive colors
- Foreground and background relationships
- Focus indicators
- Disabled treatment
- Dark-mode values
- High-contrast behavior
- Brand-specific overrides
A semantic class name also does not prove that the underlying colors meet contrast requirements. The implementation must still be evaluated in its rendered themes.
CVA can preserve token usage once the tokens exist. It cannot establish whether those tokens are correct.
The className escape hatch is a policy decision
Most reusable components accept a className prop because consumers eventually need layout adjustments or application-specific composition.
That flexibility comes with a cost.
A consumer can write:
Depending on class order and the project’s merge utility, those classes may override the component’s visual contract.
There is no universally correct response.
A tightly governed design-system package may restrict arbitrary classes and expose supported layout props instead. A product-local component library may deliberately allow overrides because it values adaptation over strict consistency.
The important part is to make that choice intentionally.
When className is accepted, the component contract should state that it is an escape hatch. Reviews and tests should not pretend that every visual decision remains centrally controlled after unrestricted overrides are allowed.
CVA centralizes the default path. It does not prevent consumers from bypassing it.
Performance is not the reason to adopt CVA
CVA is a small class-composition library. It is not a build-time compiler.
Calling a generated variant function produces a class string when the function executes:
In a server-rendered or statically generated component, that work can happen before the HTML reaches the browser. In a client component, it happens when the component renders.
Tailwind generating CSS during the build is a separate process from CVA selecting class names at runtime.
For most component libraries, the cost of composing a small class string is unlikely to determine application performance. The more meaningful performance questions are usually:
- Did the component require client-side JavaScript at all?
- Was a provider mounted above static pages?
- Are large dependencies entering the client bundle?
- Are images sized and prioritized correctly?
- Is data being fetched or refetched unnecessarily?
- Does interaction trigger expensive rendering elsewhere?
Choose CVA because it improves component contracts and centralizes variant styling.
Do not justify it with invented counts of string operations, guaranteed Core Web Vitals improvements, or claims that every class combination is precomputed at build time.
Where CVA fits in a component system
CVA works best when a component has:
- A finite set of named visual variants
- Repeated use across several consumers
- Stable semantic axes such as intent, size, density, or emphasis
- Shared base classes
- Conditional styles that are awkward to express repeatedly
- A TypeScript API that should be derived from the style definition
Buttons, badges, alerts, inputs, cards, tabs, navigation items, and typography primitives are common candidates.
It is less useful when:
- The component appears once
- Styling is mostly arbitrary layout composition
- Consumers need open-ended values rather than finite variants
- State transitions require a real reducer or state machine
- The variation changes rendering structure more than styling
- A typed metadata map expresses the problem more clearly
Not every conditional class needs a library. A two-branch local expression can be easier to understand than an exported variant definition.
The goal is not to maximize CVA usage. The goal is to create predictable component APIs where repetition and drift justify the abstraction.
Testing the contract
CVA makes variant output deterministic, but deterministic output is not the same as correct output.
A useful test strategy separates the guarantees:
Test area | What it verifies | What it does not verify |
|---|---|---|
Typechecking | Accepted variant values, required props, and prohibited combinations represented through TypeScript | Rendered appearance or runtime accessibility |
Variant unit tests | Expected classes for defaults, explicit variants, and compound conditions | Browser-computed styles or contrast |
Component tests | Native attributes, loading behavior, accessible names, and class composition | Full visual fidelity across themes |
Accessibility tests | Roles, names, disabled state, and common automated violations | Every keyboard interaction or subjective usability issue |
Storybook or visual regression | Appearance across documented variants, themes, and viewports | Undocumented consumer overrides |
End-to-end tests | Behavior inside a real product flow | Exhaustive coverage of every visual combination |
For the variant function, a focused unit test is enough:
The component test should verify behavior rather than repeat every class assertion:
Type-level constraints can be protected with compile-time fixtures or explicit @ts-expect-error cases:
None of these tests proves that the final foreground and background colors have sufficient contrast. That requires rendered-style or visual accessibility testing against the actual token values.
Migrating without replacing every component at once
A CVA migration should begin with an inventory, not a package installation.
Start by collecting the APIs that already exist:
These may all represent the same product intent under different names.
The migration work is deciding on the canonical vocabulary:
That naming decision matters more than converting a ternary expression into a variants object.
A practical migration sequence is:
- Inventory the variants that are actually used.
- Separate behavioral props from visual props.
- Replace appearance-based names with semantic names where appropriate.
- Define design-token-backed base and variant classes.
- Introduce CVA at the shared primitive boundary.
- Add temporary aliases only where consumers cannot migrate immediately.
- Update stories, tests, and documentation.
- Remove duplicate implementations after usage reaches zero.
Compatibility should be temporary and visible:
This allows incremental adoption without pretending the old API should survive forever.
A compatibility prop that remains indefinitely becomes a second public contract. The component is then carrying the migration instead of completing it.
What CVA does not solve
CVA does not decide:
- Which components belong in the shared library
- Which variant names match product intent
- Whether two variants are visually distinct enough
- Whether a component should be polymorphic
- Whether arbitrary
classNameoverrides are permitted - How focus, loading, and disabled behavior work
- Whether variants meet accessibility requirements
- How components are versioned and distributed
- How breaking changes are communicated
- Which teams own the component contract
Those are design-system and platform decisions.
The library can make a weak decision consistent just as easily as a good one. A poorly named blue variant remains poorly named after CVA gives it autocomplete.
Standardization is useful only when the standardized contract deserves to spread.
The architectural value is narrower and more durable
CVA does not transform a component library into an enterprise design system.
It gives a component one declarative place to describe base classes, named visual axes, defaults, and conditional styling relationships. TypeScript can derive the accepted variant values from that definition. Additional unions can restrict combinations that the raw variant map cannot express.
That is a useful boundary.
The component still owns behavior. The token system still owns visual meaning. Tests still own evidence. Documentation still explains intent. Governance still decides how the contract changes.
A design system scales when those boundaries reinforce one another.
CVA contributes by making one of them clearer.
The goal is not to make every component clever. It is to make the common decisions boring, explicit, and difficult to reinterpret accidentally.
Written by
Daniel Mark
Senior Frontend Engineer
Daniel Mark is a senior frontend engineer and product consultant focused on frontend architecture, developer tooling, and production-grade web products. He writes about design systems, performance, SEO, platform engineering, and the technical decisions behind reliable, maintainable user experiences.