Skip to main content

← Blog

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:

1
type ButtonProps = {
2
variant?: "primary" | "secondary" | "danger";
3
small?: boolean;
4
fullWidth?: boolean;
5
muted?: boolean;
6
loading?: boolean;
7
className?: string;
8
};
9
10
export function Button({
11
variant = "primary",
12
small,
13
fullWidth,
14
muted,
15
loading,
16
className,
17
...props
18
}: ButtonProps) {
19
const classes = cn(
20
"inline-flex items-center justify-center rounded-md font-medium",
21
variant === "primary" &&
22
"bg-blue-600 text-white hover:bg-blue-700",
23
variant === "secondary" &&
24
"bg-gray-100 text-gray-900 hover:bg-gray-200",
25
variant === "danger" &&
26
"bg-red-600 text-white hover:bg-red-700",
27
small ? "h-8 px-3 text-sm" : "h-10 px-4 text-base",
28
fullWidth && "w-full",
29
muted && "opacity-60",
30
loading && "pointer-events-none",
31
className,
32
);
33
34
return <button className={classes} {...props} />;
35
}

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:

1
import { cva, type VariantProps } from "class-variance-authority";
2
3
export const buttonVariants = cva(
4
[
5
"inline-flex items-center justify-center gap-2",
6
"rounded-md font-medium transition-colors",
7
"focus-visible:outline-none focus-visible:ring-2",
8
"disabled:cursor-not-allowed disabled:opacity-50",
9
],
10
{
11
variants: {
12
intent: {
13
primary:
14
"bg-action-primary text-on-action-primary hover:bg-action-primary-hover",
15
secondary:
16
"bg-action-secondary text-on-action-secondary hover:bg-action-secondary-hover",
17
danger:
18
"bg-action-danger text-on-action-danger hover:bg-action-danger-hover",
19
},
20
size: {
21
sm: "h-8 px-3 text-sm",
22
md: "h-10 px-4 text-sm",
23
lg: "h-12 px-6 text-base",
24
},
25
width: {
26
content: "w-auto",
27
full: "w-full",
28
},
29
},
30
defaultVariants: {
31
intent: "primary",
32
size: "md",
33
width: "content",
34
},
35
},
36
);
37
38
export type ButtonVariantProps = VariantProps<
39
typeof buttonVariants
40
>;
41

This establishes three visual axes:

  • intent
  • size
  • width

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:

1
import type { ButtonHTMLAttributes } from "react";
2
3
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
4
ButtonVariantProps & {
5
loading?: boolean;
6
};
7
8
export function Button({
9
intent,
10
size,
11
width,
12
loading = false,
13
disabled,
14
className,
15
children,
16
...props
17
}: ButtonProps) {
18
const isDisabled = disabled || loading;
19
20
return (
21
<button
22
{...props}
23
type={props.type ?? "button"}
24
disabled={isDisabled}
25
aria-busy={loading || undefined}
26
className={cn(
27
buttonVariants({ intent, size, width }),
28
className,
29
)}
30
>
31
{loading ? <Spinner aria-hidden="true" /> : null}
32
<span>{children}</span>
33
</button>
34
);
35
}

CVA decides which visual classes correspond to intentsize, and width.

The component decides that:

  • A loading button is disabled.
  • The native disabled attribute is applied.
  • aria-busy reflects 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:

1
// TypeScript error: "giant" is not a valid size.
2
<Button size="giant">Continue</Button>

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:

1
const buttonVariants = cva("...", {
2
variants: {
3
intent: {
4
primary: "...",
5
secondary: "...",
6
danger: "...",
7
},
8
appearance: {
9
solid: "...",
10
outline: "...",
11
ghost: "...",
12
},
13
},
14
});

The following call remains type-correct:

1
buttonVariants({
2
intent: "danger",
3
appearance: "ghost",
4
});

Both values exist. CVA does not know that the combination violates a product rule.

That restriction belongs in the component’s TypeScript contract:

1
type GeneratedVariants = VariantProps<typeof buttonVariants>;
2
3
type CommonVariants = Omit<
4
GeneratedVariants,
5
"intent" | "appearance"
6
>;
7
8
type AllowedButtonVariants =
9
| (CommonVariants & {
10
intent?: "primary" | "secondary";
11
appearance?: "solid" | "outline" | "ghost";
12
})
13
| (CommonVariants & {
14
intent: "danger";
15
appearance?: "solid" | "outline";
16
});
17
18
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> &
19
AllowedButtonVariants & {
20
loading?: boolean;
21
};

Now the prohibited combination fails during typechecking:

1
// TypeScript error: ghost is not permitted for danger buttons.
2
<Button intent="danger" appearance="ghost">
3
Delete account
4
</Button>

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:

1
const buttonVariants = cva("...", {
2
variants: {
3
intent: {
4
primary: "",
5
secondary: "",
6
danger: "",
7
},
8
appearance: {
9
solid: "",
10
outline: "border bg-transparent",
11
ghost: "bg-transparent shadow-none",
12
},
13
size: {
14
sm: "h-8 px-3 text-sm",
15
md: "h-10 px-4 text-sm",
16
lg: "h-12 px-6 text-base",
17
},
18
},
19
compoundVariants: [
20
{
21
intent: "primary",
22
appearance: "solid",
23
className:
24
"bg-action-primary text-on-action-primary hover:bg-action-primary-hover",
25
},
26
{
27
intent: "danger",
28
appearance: "outline",
29
className:
30
"border-action-danger text-action-danger hover:bg-action-danger-subtle",
31
},
32
{
33
intent: ["primary", "secondary"],
34
appearance: "ghost",
35
className:
36
"text-foreground hover:bg-surface-interactive",
37
},
38
],
39
defaultVariants: {
40
intent: "primary",
41
appearance: "solid",
42
size: "md",
43
},
44
});

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:

1
type Status = "complete" | "current" | "upcoming";
2
3
type StatusMetadata = {
4
label: string;
5
icon: React.ComponentType<{ className?: string }>;
6
};
7
8
const statusMetadata = {
9
complete: {
10
label: "Complete",
11
icon: CheckIcon,
12
},
13
current: {
14
label: "Current",
15
icon: CircleIcon,
16
},
17
upcoming: {
18
label: "Upcoming",
19
icon: ClockIcon,
20
},
21
} satisfies Record<Status, StatusMetadata>;

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:

1
const statusBadgeVariants = cva(
2
"inline-flex items-center gap-1.5 rounded-full",
3
{
4
variants: {
5
status: {
6
complete:
7
"bg-status-success-subtle text-status-success",
8
current:
9
"bg-status-info-subtle text-status-info",
10
upcoming:
11
"bg-surface-muted text-foreground-muted",
12
},
13
},
14
},
15
);

The final component composes both contracts:

1
type StatusBadgeProps = {
2
status: Status;
3
};
4
5
export function StatusBadge({ status }: StatusBadgeProps) {
6
const metadata = statusMetadata[status];
7
const Icon = metadata.icon;
8
9
return (
10
<span className={statusBadgeVariants({ status })}>
11
<Icon aria-hidden="true" className="size-3.5" />
12
{metadata.label}
13
</span>
14
);
15
}

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:

1
primary: "bg-blue-600 text-white hover:bg-blue-700"

This describes intent:

1
primary:"bg-action-primary text-on-action-primary hover:bg-action-primary-hover"

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:

1
<Button
2
intent="primary"
3
className="bg-green-500 text-black"
4
>
5
Continue
6
</Button>

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:

1
buttonVariants({
2
intent: "primary",
3
size: "md",
4
});

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:

1
describe("buttonVariants", () => {
2
it("uses the default visual contract", () => {
3
const classes = buttonVariants();
4
5
expect(classes).toContain("bg-action-primary");
6
expect(classes).toContain("h-10");
7
});
8
9
it("applies the outlined danger treatment", () => {
10
const classes = buttonVariants({
11
intent: "danger",
12
appearance: "outline",
13
});
14
15
expect(classes).toContain("border-action-danger");
16
expect(classes).toContain("text-action-danger");
17
});
18
});

The component test should verify behavior rather than repeat every class assertion:

1
it("disables the native button while loading", () => {
2
render(<Button loading>Save changes</Button>);
3
4
const button = screen.getByRole("button", {
5
name: "Save changes",
6
});
7
8
expect(button).toBeDisabled();
9
expect(button).toHaveAttribute("aria-busy", "true");
10
});
11

Type-level constraints can be protected with compile-time fixtures or explicit @ts-expect-error cases:

1
// @ts-expect-error danger buttons cannot use ghost appearance
2
<Button intent="danger" appearance="ghost">
3
Delete
4
</Button>

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:

1
<Button primary />
2
<Button kind="main" />
3
<ActionButton color="blue" />
4
<SubmitButton emphasis="high" />

These may all represent the same product intent under different names.

The migration work is deciding on the canonical vocabulary:

1
<Button intent="primary" />

That naming decision matters more than converting a ternary expression into a variants object.

A practical migration sequence is:

  1. Inventory the variants that are actually used.
  2. Separate behavioral props from visual props.
  3. Replace appearance-based names with semantic names where appropriate.
  4. Define design-token-backed base and variant classes.
  5. Introduce CVA at the shared primitive boundary.
  6. Add temporary aliases only where consumers cannot migrate immediately.
  7. Update stories, tests, and documentation.
  8. Remove duplicate implementations after usage reaches zero.

Compatibility should be temporary and visible:

1
type LegacyButtonProps = ButtonProps & {
2
primary?: boolean;
3
};
4
5
export function Button({
6
primary,
7
intent,
8
...props
9
}: LegacyButtonProps) {
10
const resolvedIntent = primary ? "primary" : intent;
11
12
return <InternalButton intent={resolvedIntent} {...props} />;
13
}
14

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 className overrides 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.


Portrait of Daniel Mark

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.