Class Variance Authority (CVA) is a utility library for building type-safe component variant APIs in TypeScript. It provides a declarative way to map component props—such as variant, size, and color—to CSS class strings, eliminating the fragile conditional logic that typically accumulates inside component files. CVA pairs naturally with utility-first CSS frameworks like Tailwind CSS, but it works with any class-based styling approach.
In an internal design system, CVA serves as the backbone for every shared UI primitive. Buttons, badges, inputs, cards, and alerts all expose a consistent props surface—variant and size at minimum—because their styling is driven by a single cva() call. This consistency reduces onboarding friction: once a developer learns the pattern for one component, every other component works the same way.
Beyond developer ergonomics, CVA gives you compile-time safety. The VariantProps helper type extracts a component’s accepted variants directly from its cva definition, so the TypeScript compiler catches invalid prop combinations before the code ever reaches a browser. This is especially powerful for large teams maintaining shared component libraries, where a typo in a variant name can otherwise slip through code review unnoticed.
Every cva() call starts with a string (or array) of base classes that apply unconditionally to the component. These define the structural styles—display, position, font family, transition— that remain constant regardless of which variant or size the consumer selects.
Variants are named axes of variation. Each variant key (e.g. intent, size) maps to an object whose own keys are the allowed values and whose values are the CSS classes to apply. CVA merges the selected class strings at runtime, producing a single, predictable class list.
Sometimes a specific combination of variant values requires additional or overriding styles. Compound variants let you declare rules like “when intent is danger and size is lg, also apply these classes.” This avoids complex ternary logic in the component body.
The VariantProps generic extracts the union types for every variant axis from a cva return value. By intersecting it with your component’s own props interface, you get full autocompletion and compile-time validation for every variant prop the component accepts—no manual type definitions required.
CVA is distributed as a single npm package with zero runtime dependencies. Install it alongside your preferred CSS framework:
npm install class-variance-authority
# or
pnpm add class-variance-authority
The cva() function accepts a base class string and a configuration object containing variants, compoundVariants, and defaultVariants.
import { cva, type VariantProps } from "class-variance-authority";
const buttonVariants = cva(
// Base classes — always applied
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground",
link:
"text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
The value returned by cva() is a callable function. Pass an object with the desired variant values and it returns the merged class string:
// Returns base + variant classes merged into one string
buttonVariants({ variant: "destructive", size: "lg" });
// => "inline-flex items-center justify-center rounded-md font-medium
// transition-colors ... bg-destructive text-destructive-foreground
// hover:bg-destructive/90 h-11 rounded-md px-8"
// Omitting a variant uses the default
buttonVariants({ size: "sm" });
// => uses variant: "default" automatically
VariantProps derives a type from the cva return value so your React component props stay in sync with the variant definition:
import * as React from "react";
// Extract variant prop types automatically
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
VariantProps<typeof buttonVariants>;
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
ref={ref}
className={buttonVariants({ variant, size, className })}
{...props}
/>
);
}
);
Button.displayName = "Button";
When certain variant combinations need special treatment, use compoundVariants to layer additional classes:
const alertVariants = cva(
"relative w-full rounded-lg border p-4 text-sm",
{
variants: {
variant: {
default: "bg-background text-foreground",
info: "border-blue-500/50 text-blue-700 bg-blue-50",
success: "border-green-500/50 text-green-700 bg-green-50",
warning: "border-yellow-500/50 text-yellow-700 bg-yellow-50",
destructive: "border-red-500/50 text-red-700 bg-red-50",
},
size: {
sm: "p-2 text-xs",
default: "p-4 text-sm",
lg: "p-6 text-base",
},
},
compoundVariants: [
{
variant: "destructive",
size: "lg",
class: "border-2 font-semibold", // extra emphasis for large destructive alerts
},
{
variant: "info",
size: "sm",
class: "rounded-md", // tighter radius on small info badges
},
],
defaultVariants: {
variant: "default",
size: "default",
},
}
);
CVA returns a plain string, so you can combine it with any class merging utility. The internal component library uses tailwind-merge via a small cn() helper to handle Tailwind class conflicts:
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
// Usage inside a component
<button className={cn(buttonVariants({ variant, size }), className)} />
The internal component library uses CVA as the single source of truth for every component’s visual API. Each shared primitive—Button, Badge, Input, Card, Dialog, Toast—exports both the React component and its cva variants object, so consuming applications can reuse the variant logic outside of React (for example, in server-rendered email templates or Storybook controls).
Because CVA variant definitions are plain objects, they can be extended or overridden per-product while keeping the base consistent. In a monorepo with multiple frontends (marketing site, dashboard, embedded widget), each product imports the shared buttonVariants and optionally wraps it with product-specific compound variants. This guarantees visual consistency across products without forcing identical styling.
CVA variant keys map directly to Storybook argTypes. A single utility function can introspect a cva config and generate the corresponding Storybook controls, producing a complete variant matrix with zero manual configuration:
// Auto-generate Storybook argTypes from CVA config
function cvaArgTypes<T extends (...args: any) => any>(
variants: Parameters<T>[0]
) {
return Object.fromEntries(
Object.entries(variants).map(([key, values]) => [
key,
{
control: { type: "select" },
options: Object.keys(values as Record<string, unknown>),
},
])
);
}
CVA variant values can reference design token classes generated by tools like Tailwind’s theme configuration or OKLCH token systems. This creates a clean bridge between design tokens defined in Figma or a token JSON file and the actual component API that developers consume.
Keep all variant definitions in a co-located variants.ts file next to the component. This separates styling logic from rendering logic and makes variants independently importable:
// src/components/button/variants.ts
import { cva } from "class-variance-authority";
export const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
);
// src/components/button/button.tsx
import { buttonVariants } from "./variants";
import { cn } from "@/lib/utils";
import type { VariantProps } from "class-variance-authority";
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
);
Button.displayName = "Button";
Wrap the base variant function to add product-specific variants without modifying the shared library:
// apps/dashboard/src/components/button.tsx
import { buttonVariants as baseVariants } from "@internal/ui";
import { cva } from "class-variance-authority";
// Extend with dashboard-specific variants
const dashboardButtonVariants = cva(
baseVariants.config.base,
{
variants: {
...baseVariants.config.variants,
variant: {
...baseVariants.config.variants.variant,
// Add a dashboard-only "premium" variant
premium:
"bg-gradient-to-r from-amber-500 to-orange-600 text-white shadow-lg hover:shadow-xl",
},
},
defaultVariants: baseVariants.config.defaultVariants,
}
);
Create a wrapper that applies different variant values at different breakpoints. This is useful for components that change size across screen widths:
// Utility: apply CVA variants responsively via data attributes
type ResponsiveVariant<T> = {
base: T;
sm?: T;
md?: T;
lg?: T;
};
function responsiveSize(
sizeMap: ResponsiveVariant<"sm" | "default" | "lg">
): string {
const classes = [buttonVariants({ size: sizeMap.base })];
if (sizeMap.sm) classes.push(`sm:${buttonVariants({ size: sizeMap.sm })}`);
if (sizeMap.md) classes.push(`md:${buttonVariants({ size: sizeMap.md })}`);
if (sizeMap.lg) classes.push(`lg:${buttonVariants({ size: sizeMap.lg })}`);
return classes.join(" ");
}
Use a boolean variant axis and compound variants to adjust padding when an icon is present:
const iconButtonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors",
{
variants: {
size: {
sm: "h-9 text-xs",
default: "h-10 text-sm",
lg: "h-11 text-base",
},
hasIcon: {
true: "",
false: "",
},
},
compoundVariants: [
{ size: "sm", hasIcon: true, class: "px-2.5" },
{ size: "sm", hasIcon: false, class: "px-3" },
{ size: "default", hasIcon: true, class: "px-3" },
{ size: "default", hasIcon: false, class: "px-4" },
{ size: "lg", hasIcon: true, class: "px-5" },
{ size: "lg", hasIcon: false, class: "px-8" },
],
defaultVariants: {
size: "default",
hasIcon: false,
},
}
);
defaultVariants: If you omit defaultVariants, calling the variant function without specifying every axis returns only the base classes. This leads to unstyled components that appear broken. Always define sensible defaults for every variant axis.twMerge: CVA concatenates class strings; it does not resolve conflicts. When a consumer passes a className that conflicts with a variant class (e.g. bg-red-500 overriding bg-primary), the later class in the stylesheet wins by specificity—not by position in the string. Use tailwind-merge (via a cn() helper) to ensure the consumer’s override actually takes effect.borderRadius, paddingX, paddingY, etc. defeats the purpose of a design system. Prefer semantic axes like size that bundle multiple CSS properties together into meaningful presets.cva result alongside the component.node_modules, add that path to content in tailwind.config.ts or the classes will be purged in production.style prop or a CSS-in-JS call will not work. Keep styling in one paradigm per component.twMerge or structure your classes to avoid conflicts.