shadcn ui : Variant API with cva
shadcn components express their visual variants through class-variance-authority (cva). The single most common source of styling bugs in a shadcn project is misuse of cva or its companion helper cn(). ALWAYS internalise this skill before adding, editing, or reviewing a variants object.
Quick Reference
cva in three lines
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva("inline-flex items-center", {
variants: { variant: { default: "bg-primary", outline: "border" } },
defaultVariants: { variant: "default" },
})
Three rules apply to every cva call :
- ALWAYS pass the result of
buttonVariants({ ... })throughcn(...)together with any callerclassName. NEVER return the rawcva()output to the DOM. - ALWAYS export
VariantProps<typeof buttonVariants>(directly or via aPropstype) so consumers can extend the component. NEVER hand-roll the variant union by typing strings. - ALWAYS list variant values as quoted string keys (
outline: "..."), never raw booleans or numbers as keys ; cva treats keys as strings under the hood and TypeScript inference relies on it.
Canonical Button pattern (verbatim from shadcn registry, Tailwind v4)
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border bg-background hover:bg-accent",
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 rounded-md px-3",
lg: "h-10 rounded-md px-6",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
The full references/examples.md carries the unabbreviated registry source plus extension recipes.
cva signature breakdown
The function lives in the class-variance-authority package (verified at https://cva.style/docs/api-reference, 2026-05-19) :
function cva(base: ClassValue | ClassValue[], config?: Config): VariantFn
| Position | Name | Type | Role |
|---|---|---|---|
| 1 | base |
string OR string[] OR any clsx value |
Classes that ALWAYS apply, regardless of variant |
| 2 | config.variants |
object : variant-key to (value-key to class string) | The variant axis enumeration |
| 2 | config.compoundVariants |
array of { ...match-keys, class } |
Extra classes applied when multiple variants match simultaneously |
| 2 | config.defaultVariants |
object : variant-key to default value-key | Fallback when caller omits the prop |
base : string or string array
ALWAYS prefer a string array when the base list is long enough that a single line becomes unreadable :
const card = cva([
"rounded-lg border bg-card text-card-foreground shadow-sm",
"transition-colors",
])
Both forms produce identical output. NEVER concatenate with + " " ; pass the array verbatim.
variants : the variant axis enumeration
Each top-level key is one variant axis (variant, size, tone, ...). Each value is a map from value-name to a class string (or array of strings for clsx-style composition) :
variants: {
tone: {
info: "bg-blue-50 text-blue-900",
success: ["bg-green-50", "text-green-900"],
danger: "bg-red-50 text-red-900",
},
}
ALWAYS use quoted string keys. NEVER use raw identifiers like true: or 1: without quotes ; cva will accept them, but TypeScript inference via VariantProps then surfaces awkward union members. For boolean-shaped variants, use "true" and "false" as string keys (see references/anti-patterns.md AP-3).
compoundVariants : combination-only overrides
Each entry is an object that lists one OR MORE variant-keys to match, plus a class string (or array) to apply when ALL listed keys match the caller's invocation :
compoundVariants: [
{ variant: "outline", size: "sm", class: "ring-1 ring-ring/20" },
{ variant: "outline", size: "lg", class: "ring-2 ring-ring/30" },
]
ALWAYS understand the evaluation order : defaultVariants resolve first (filling in missing props), THEN variants classes apply, THEN compoundVariants classes apply LAST. The last-applied class wins via tailwind-merge only because of position in the string ; if a compound entry needs to override a variant entry, ALWAYS rely on cn() (and therefore twMerge) to resolve the conflict. NEVER assume the compound class wins solely because it is declared in compoundVariants ; without twMerge, conflicting utilities BOTH appear in the DOM and CSS specificity decides arbitrarily.
defaultVariants : the no-prop fallback
defaultVariants: { variant: "default", size: "default" }
ALWAYS provide a defaultVariants entry for EVERY variant axis. Consumers may omit any prop ; missing defaults produce undefined lookups that yield no classes for that axis. Setting a variant to null in defaultVariants explicitly opts out of any default for that axis (verified at https://cva.style/docs/api-reference, 2026-05-19).
VariantProps inference pattern
VariantProps is a TypeScript helper exported by class-variance-authority (verified at https://cva.style/docs/getting-started/typescript). Use it to derive the variant prop union from the cva return value :
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(/* ... */)
type ButtonVariantProps = VariantProps<typeof buttonVariants>
// ^ { variant?: "default" | "outline" | "ghost" | "link" | null | undefined,
// size?: "default" | "sm" | "lg" | "icon" | null | undefined }
The canonical shadcn pattern composes VariantProps with native DOM props via TypeScript intersection :
type ButtonProps =
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> &
{ asChild?: boolean }
ALWAYS export buttonVariants (the cva return value) AND the component. Without the variants export consumers cannot reuse the variant classes for sibling components (link styled as a button, Next.js <Link> carrying button visuals, etc.). NEVER duplicate the variant-class strings into a second cva() call ; reuse the existing one.
Making a variant required
cva makes every variant optional by default. To force a variant to be required, use TypeScript utility types (verified pattern at https://cva.style/docs/getting-started/typescript) :
type ButtonProps =
Omit<VariantProps<typeof buttonVariants>, "variant"> &
Required<Pick<VariantProps<typeof buttonVariants>, "variant">>
cn() helper usage rules
The cn() helper composes clsx and tailwind-merge. The shadcn CLI ships it at @/lib/utils (verified, present in every project initialised with pnpm dlx shadcn@latest init) :
// lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Two responsibilities are stacked :
| Layer | Library | Responsibility |
|---|---|---|
| Inner | clsx |
CONDITIONAL composition : flatten arrays, filter falsy, expand { "foo": cond } objects |
| Outer | tailwind-merge |
CONFLICT resolution : twMerge("px-2 p-3") returns "p-3", dropping the override-loser |
The cn() ALWAYS-rule
ALWAYS pass any composed Tailwind class string through cn() before assigning to className. This applies to :
- The output of
buttonVariants({ variant, size, className })(callers can passclassNameto override). - Any inline conditional class composition (
cn("base", active && "bg-primary")). - Any prop-pass-through where the parent forwards
classNameto a child.
NEVER write either of these :
// WRONG : template-string concat. Conflicting utilities both ship to DOM, twMerge bypassed.
<button className={`bg-primary ${className}`}>
// WRONG : plus-string concat. Same problem ; also brittle on undefined.
<button className={"bg-primary " + (active ? "ring-2" : "")}>
ALWAYS write :
<button className={cn("bg-primary", active && "ring-2", className)}>
The conflict-resolution role of twMerge is what makes caller className "win" over base classes. Skip it and your component silently fails to honour parent overrides. See references/anti-patterns.md AP-1 for the full damage report.
Variant evaluation order
When buttonVariants({ variant: "outline", size: "sm", className: "bg-red-500" }) is called, classes accumulate in this order :
- Base : the first cva argument (e.g.
"inline-flex shrink-0 ..."). - defaultVariants : applied for any axis the caller did not specify. (Here the caller specified both
variantandsize, so defaults are skipped.) - variants[axis][value] : for each axis the caller specified :
variant.outlinethensize.sm. - compoundVariants : every entry whose match-keys ALL equal the caller's props.
- className (when included in the call object) : the caller's literal override string.
The accumulator is then passed through clsx (to flatten) and twMerge (to resolve conflicts). The LAST conflicting utility wins ; the order of accumulation determines that last-position.
Why ordering matters in practice
Suppose variant.outline sets border-input and a compound entry { variant: "outline", size: "lg" } sets border-primary. Because the compound runs AFTER the variant, twMerge correctly drops border-input. If you flip the conceptual model and treat compound as "an extra hint" rather than "the final word", you will write compound classes that lose to variants and wonder why nothing changes. ALWAYS treat compound entries as final-position overrides ; NEVER expect them to be merged via specificity.
asChild Slot pattern
Shadcn components accept an asChild?: boolean prop. When true, the component renders its child as the actual DOM element while still applying all of its own classes and props. The mechanism is Radix's Slot.Root (since the Feb 2026 unified radix-ui package ; pre-unification it was @radix-ui/react-slot's Slot) :
import { Slot } from "radix-ui"
function Button({ asChild = false, className, ...props }) {
const Comp = asChild ? Slot.Root : "button"
return <Comp className={cn(buttonVariants({ ...props, className }))} {...props} />
}
Usage : render a button-styled Next.js <Link> or a react-router NavLink without rendering an actual <button> :
<Button asChild variant="outline">
<Link href="/about">About</Link>
</Button>
asChild rules
- ALWAYS pass exactly ONE child to a component with
asChild. RadixSlotthrows on zero or multiple children. - ALWAYS ensure the child accepts the props the parent forwards (
className,onClick, ref). A plain<div>accepts most ; a custom component must spread props. - NEVER nest
asChildmore than one level without inspecting forwarded refs ; ref-forwarding through multiple Slots requiresSlot.Root+Slot.Slottable(see Radix docs). - NEVER use
asChildto inject a SECOND interactive element. The combined element must still be a single accessible widget (button OR link, not both).
See references/anti-patterns.md AP-5 for the multi-child failure mode.
Companion Skills
- shadcn-core-stack : where cva and tailwind-merge sit in the dependency layer cake
- shadcn-syntax-button : button variants and sizes in full, plus
asChildworked examples - shadcn-syntax-form : Form components compose
cn()for FormControl wiring - shadcn-errors-styling-conflicts : debugging duplicate utilities, fast-refresh lint warnings on cva exports
- shadcn-errors-radix-controlled :
asChildmisuse and Slot single-child requirement
Reference Links
- references/methods.md : full
cva()TypeScript signature,VariantPropsdefinition,cn()source,SlotAPI surface - references/examples.md : minimal Badge, verbatim Button source, compound-variant example, variant extension, WRONG-vs-RIGHT className override
- references/anti-patterns.md : six numbered anti-patterns with WHY and FIX
Sources
All claims in this skill trace to URLs in SOURCES.md :
- https://cva.style/docs (cva introduction)
- https://cva.style/docs/api-reference (cva signature)
- https://cva.style/docs/getting-started/typescript (VariantProps and required-variant patterns)
- https://github.com/dcastil/tailwind-merge (twMerge semantics)
- https://ui.shadcn.com/docs/components/radix/button (canonical Button source)
- https://www.radix-ui.com/primitives (Slot primitive)
Verified 2026-05-19.