Skip to main content

cva: declarative variant management and how it works

What you learn in this chapter

You learn how to use cva (class-variance-authority) and what it does internally. You understand how to manage a component's style variants declaratively, including what cva "does not do" (conflict resolution).

What is cva?

cva is a library for defining style variants declaratively.

A "variant" is a visual variation of the same component. A button has variants such as "lead (primary)," "supporting (secondary)," and "warning (destructive)."

The Button's variants
├─ default (lead)
├─ secondary (supporting)
├─ ghost (transparent)
└─ destructive (warning / red)

Why is cva needed?

Without cva, managing variants turns into a storm of conditional branches.

// ❌ Without cva: a storm of if statements + hardcoded colors
function Button({ variant, size, children }) {
let className = "inline-flex items-center justify-center rounded-md"

if (variant === "default") {
className += " bg-blue-500 text-white hover:bg-blue-600"
} else if (variant === "secondary") {
className += " bg-gray-200 text-gray-800 hover:bg-gray-300"
} else if (variant === "destructive") {
className += " bg-red-500 text-white hover:bg-red-600"
}

if (size === "sm") {
className += " h-8 px-3"
} else if (size === "lg") {
className += " h-10 px-6"
} else {
className += " h-9 px-4"
}

return <button className={className}>{children}</button>
}

The problems with this approach.

  • Low readability: it is hard to grasp which variant has which style
  • Low maintainability: adding or changing variants is a hassle
  • No type safety: passing an invalid variant name does not error
  • Hardcoded colors: it writes bg-blue-500 directly instead of the tokens you learned in the chapter on design tokens (bg-primary)

The solution with cva

// ✅ With cva: declarative + design tokens
import { cva } from "class-variance-authority"

const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: "bg-destructive text-white hover:bg-destructive/90",
},
size: {
sm: "h-8 px-3",
default: "h-9 px-4",
lg: "h-10 px-6",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

function Button({ variant, size, children }) {
return <button className={buttonVariants({ variant, size })}>{children}</button>
}

You can see at a glance which variant has which style, and the colors use the tokens from the chapter on design tokens.

info

cva's package name is class-variance-authority. Import it with import { cva } from "class-variance-authority". There is also a separate package named cva on npm, but it is an empty, different thing, so do not use it (install with npm install class-variance-authority).

The basic structure of cva

const variants = cva(
"base classes (always applied)",
{
variants: {
variantName: {
optionA: "class names",
optionB: "class names",
},
},
defaultVariants: {
variantName: "optionA", // the default when nothing is specified
},
}
)

cva is "a function that returns a function"

This is the first step in understanding the internals. cva(...) returns a function, not a string.

const buttonVariants = cva("inline-flex rounded-md", {
variants: { variant: { default: "bg-primary", ghost: "bg-transparent" } },
})

// buttonVariants is a "function"
typeof buttonVariants // => "function"

// Only by calling it do you get the class-name "string"
buttonVariants({ variant: "ghost" }) // => "inline-flex rounded-md bg-transparent"
cva(base, config) → (props) => string
(create the recipe) (call it to generate the string)

The image is: you create a "recipe (the variant definition)" once, and each time you use it, you pass props to make the "dish (the class-name string)." Thanks to this design, you can place the definition in one spot and call it as many times as you like.

Practice: assembling it step by step

Step 1: Base classes only

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium")

buttonVariants()
// => "inline-flex items-center justify-center rounded-md font-medium"

Step 2: Add one variant

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
},
},
})

buttonVariants({ variant: "default" })
// => "...rounded-md font-medium bg-primary text-primary-foreground"

Step 3: Combine multiple variants

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
},
size: {
sm: "h-8 px-3 text-sm",
lg: "h-10 px-6",
},
},
})

buttonVariants({ variant: "default", size: "lg" })
// => "...font-medium bg-primary text-primary-foreground h-10 px-6"

Step 4: Add default values

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: {
default: "bg-primary text-primary-foreground",
secondary: "bg-secondary text-secondary-foreground",
},
size: { sm: "h-8 px-3", default: "h-9 px-4", lg: "h-10 px-6" },
},
defaultVariants: { variant: "default", size: "default" },
})

buttonVariants()
// => default is applied even with no arguments
// => "...font-medium bg-primary text-primary-foreground h-9 px-4"

Passing additional class names

You can pass className to add to the classes cva generates.

buttonVariants({ variant: "default", className: "mt-4 shadow-lg" })
// => "...bg-primary text-primary-foreground h-9 px-4 mt-4 shadow-lg"

This lets the component's users add custom styles. However, there is an important pitfall here.

cva only "assembles" classes — it does not resolve conflicts

cva only concatenates the base, variants, and className in order. Even if Tailwind classes conflict, it does not resolve them. You can see this when you actually run it.

const pad = cva("p-4", {
variants: { tone: { a: "p-2" } },
})

pad({ tone: "a", className: "p-8" })
// => "p-4 p-2 p-8"
// ⚠️ p-4, p-2, and p-8 all remain!

p-4, p-2, and p-8 are all padding, so ideally only the last p-8 should take effect. But cva does not judge conflicts; it concatenates everything and returns it (which one ultimately wins ends up depending on the order of the CSS definitions, and is unpredictable).

cva's job: "concatenate" base + variants + className
What cva does not do: "resolve conflicts" among Tailwind classes

That is why the shadcn/ui Button wraps cva's output with cn(...).

className={cn(buttonVariants({ variant, size, className }))}
// ↑ cn resolves conflicts in cva's concatenated result

This cn (= clsx + tailwind-merge) resolves the conflicts. We learn that mechanism in detail in the next chapter.

info

This is the answer to "why you need not just cva but also cn." Keep the division of labor in mind: cva = assemble / cn = resolve conflicts.

compoundVariants: compound conditions

You can define styles that apply only to a specific combination of variants.

const buttonVariants = cva("inline-flex items-center justify-center rounded-md font-medium", {
variants: {
variant: { default: "bg-primary text-primary-foreground", outline: "border bg-background" },
size: { sm: "h-8 px-3", lg: "h-10 px-6" },
},
compoundVariants: [
{
// only when outline and lg, make the border thicker
variant: "outline",
size: "lg",
className: "border-2",
},
],
defaultVariants: { variant: "default", size: "sm" },
})

Integration with TypeScript: VariantProps

A big advantage of cva is automatic type generation. With VariantProps, you can derive the props types automatically from the variant definition.

import { cva, type VariantProps } from "class-variance-authority"

const buttonVariants = cva("...", {
variants: {
variant: { default: "...", secondary: "..." },
size: { sm: "...", lg: "..." },
},
})

type ButtonVariants = VariantProps<typeof buttonVariants>
// => { variant?: "default" | "secondary" | null; size?: "sm" | "lg" | null }

Because the types follow the definition automatically when you change it, the accident of "the definition and the types drift apart" does not happen.

<Button variant="default">OK</Button> // ✅
<Button variant="invalid">NG</Button> // ❌ TypeScript error

We cover the type details in the TypeScript chapter.

cva in the Button (the finished form of this book)

Let's reread the buttonVariants of the Button we saw in the chapter on the finished form, this time from the cva perspective.

const buttonVariants = cva(
// base classes (shared by all buttons)
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive: "bg-destructive text-white 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-9 px-4 py-2",
sm: "h-8 px-3",
lg: "h-10 px-6",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)

The colors are all design tokens from the chapter on design tokens (bg-primary, etc.). The variants are declarative with cva. This is the form of "variant design aligned with a design system."

Summary of this chapter

  • cva is a library that manages variants declaratively (the package name is class-variance-authority)
  • cva(...) returns a function (it creates a recipe, and calling it generates the class string)
  • It consists of base classes / variants / defaultVariants / compoundVariants
  • You can auto-generate types with VariantProps
  • ⚠️ cva only concatenates classes; it does not resolve conflicts → so you wrap it with cn

In the next chapter, we dig into how that cn function (clsx + tailwind-merge) resolves conflicts, down to its internal mechanism.