Class Variance Authority (CVA) Variant System

Frontend Engineering intermediate Building with AI ~18 min read

Overview

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.

Core Concepts

Base Classes

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

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.

Compound Variants

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.

VariantProps<T> Type Helper

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.

How It Works

Installing CVA

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

Defining a Basic Variant Schema

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",
    },
  }
);

Using the Variant Function

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

Extracting TypeScript Props

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";

Compound Variants

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",
    },
  }
);

Merging with External Classes

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)} />

Practical Applications

Internal Design System

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).

Consistent Multi-Product Styling

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.

Storybook Integration

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>),
      },
    ])
  );
}

Design Token Mapping

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.

Implementation Patterns

Pattern 1: Shared Variants File

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";

Pattern 2: Extending Variants for Product Themes

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,
  }
);

Pattern 3: Responsive Variant Helpers

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(" ");
}

Pattern 4: Icon-Aware Button with Compound Variants

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,
    },
  }
);

Common Pitfalls

Further Reading