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

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
(write the definition) (call it to generate the string)

You write the variant definition once, and each time you use it, you pass props to assemble 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"

The string you pass in className is appended at the end, so the component's users can add custom styles. However, there is a pitfall in how this concatenation works.

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.

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 (you write the definition once, 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 look at how that cn function (clsx + tailwind-merge) resolves conflicts, down to its internal mechanism.