shadcn ui Agent : cva Variant API Validator
This skill is the deterministic checklist a code-review or pre-commit agent uses to verify that any cva-driven shadcn component is shaped correctly. It is a VALIDATOR, not a tutorial. For the underlying cva theory, ALWAYS read shadcn-syntax-variant-cva first. For debugging a className override that does not visually win, ALWAYS read shadcn-errors-styling-conflicts.
The validator answers exactly one question per checkpoint : PASS or FAIL. It does not propose creative refactors. When a check fails, the verdict cites the failing line and points to the canonical fix.
Quick Reference
Validator workflow (top of every review)
- Locate the cva call (search for
cva(in the diff or file). - Run the 7-point checklist from top to bottom in declared order.
- Stop at the first FAIL and emit the verdict with line reference.
- If all 7 pass, emit
VERDICT: PASSplus a one-line summary. - When called on a multi-file diff, repeat per component file independently.
The 7 checks are ordered by failure frequency observed in real shadcn projects : base, variants shape, compoundVariants order, defaultVariants, VariantProps export, cn() merge, asChild Slot. The first three catch over half of every cva regression seen in shadcn-ui/ui issues.
The 7-point checklist (memorize)
| # | Check | One-line pass criterion |
|---|---|---|
| 1 | Base | First positional argument to cva is a string OR a string array |
| 2 | Variants shape | Every variant value is a string OR a string array, never a function, never nested cn() |
| 3 | compoundVariants order | compoundVariants declared AFTER variants in the config object literal |
| 4 | defaultVariants | Every variant axis has an entry in defaultVariants (or explicit null) |
| 5 | VariantProps export | The cva return value is exported AND VariantProps<typeof X> is in the props type |
| 6 | cn() merge | Component returns className={cn(xVariants({ ..., className }))}, NEVER template-string concat |
| 7 | asChild Slot | If asChild?: boolean is in props, body renders <Slot.Root> (or <Slot>) when true, native tag when false |
When the answer to any of the seven is "no" the verdict is FAIL ; cite the exact line, name the check, and reference the canonical pattern in references/examples.md.
One-glance verdict format
Every validator pass MUST emit the verdict in this exact shape so downstream pipes can grep it :
VERDICT: PASS (or FAIL)
COMPONENT: <file path>:<line of cva call>
FAILED CHECK: <check number and name> ← omit on PASS
REASON: <one sentence> ← omit on PASS
CANONICAL FIX: references/examples.md § <section name> ← omit on PASS
The 7 checkpoints in detail
Check 1 : Base classes shape
PASS if the first argument to cva is a string literal, a template literal without interpolation, or an array of those :
cva("inline-flex items-center") // PASS : string
cva(["inline-flex", "items-center"]) // PASS : array
cva(`inline-flex items-center`) // PASS : template, no interp
FAIL if any of these shapes appear :
cva(cn("inline-flex", "items-center"), { ... }) // FAIL : cn nested inside cva ; cva already runs clsx
cva(twMerge("inline-flex", "items-center"), { ... }) // FAIL : twMerge has nothing to merge
cva("inline-flex" + " " + "items-center", { ... }) // FAIL : plus-concat ; use array
cva(getBaseClasses(), { ... }) // FAIL : dynamic base defeats static analysis
REASON the nested cn() and twMerge cases fail : cva internally passes its argument through clsx, so a pre-flattened string adds no value and obscures static analysis. The plus-concat case fails because TypeScript loses the literal type and the array form is the documented convention.
Check 2 : Variants object shape
PASS if every leaf value under variants.<axis>.<value> is a string or a string array. Quoted keys, no boolean keys, no nested objects :
variants: {
variant: {
default: "bg-primary text-primary-foreground",
outline: ["border", "bg-background", "hover:bg-accent"],
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3",
},
}
FAIL on any of these shapes :
// FAIL : nested cn() ; cva already calls clsx
variant: { default: cn("bg-primary", "text-primary-foreground") }
// FAIL : function instead of class string ; cva does not invoke variant values
variant: { default: () => "bg-primary" }
// FAIL : object nesting ; cva expects a flat string-to-string map
variant: { default: { base: "bg-primary", hover: "hover:bg-primary/90" } }
// FAIL : unquoted boolean key ; cva accepts but VariantProps inference becomes awkward
variant: { true: "bg-primary", false: "bg-secondary" }
REASON : cva runs each value through clsx once, NOT recursively. Functions, nested objects, and pre-merged cn() strings either no-op or break TypeScript inference.
Check 3 : compoundVariants order and shape
PASS if compoundVariants is declared AFTER variants and BEFORE defaultVariants in the config-object source order, and every entry is { ...matchKeys, class: "..." } (or className: "..." ; both are valid per cva docs) :
const buttonVariants = cva("inline-flex", {
variants: { /* ... */ },
compoundVariants: [
{ variant: "outline", size: "sm", class: "ring-1 ring-ring/20" },
{ variant: "outline", size: "lg", class: "ring-2 ring-ring/30" },
],
defaultVariants: { variant: "default", size: "default" },
})
FAIL on :
// FAIL : compoundVariants BEFORE variants ; cva still parses but readability and merge-debugging suffer
cva("inline-flex", {
compoundVariants: [{ variant: "outline", size: "sm", class: "ring-1" }],
variants: { /* ... */ },
})
// FAIL : entry missing class or className key
compoundVariants: [{ variant: "outline", size: "sm" }]
// FAIL : entry references a variant or value name that does not exist in variants
compoundVariants: [{ variant: "ghostly", size: "sm", class: "ring-1" }]
REASON : cva is permissive on parse order, but compound-before-variants is the canonical reading-order anti-pattern called out in shadcn-errors-styling-conflicts. A reviewer reading top-to-bottom sees a compound match for variants that have not been defined yet and is more likely to miss a typo in the match-keys. Misspelled match-keys silently never match ; cva does NOT throw and TypeScript does NOT catch a stringly-typed mismatch unless the consumer also runs VariantProps validation downstream.
Check 4 : defaultVariants completeness
PASS if defaultVariants exists AND every axis declared in variants has either a string value OR an explicit null in defaultVariants :
defaultVariants: { variant: "default", size: "default" } // PASS : both axes covered
defaultVariants: { variant: "default", size: null } // PASS : size opted out explicitly
FAIL if :
// FAIL : missing defaultVariants entirely ; every undefined prop yields no class for that axis
const x = cva("inline-flex", { variants: { variant: { default: "..." } } })
// FAIL : typo in axis name ; cva treats "varient" as an unknown axis and silently ignores it
defaultVariants: { varient: "default", size: "default" }
// FAIL : typo in value name ; cva looks up variants.variant["defualt"] and finds undefined ; no classes
defaultVariants: { variant: "defualt", size: "default" }
REASON : cva is stringly-typed inside the config object. TypeScript does NOT catch axis-name typos in defaultVariants because the config is a structural object literal accepted as Config. The typo silently disables the default and components render unstyled when the consumer omits the prop. The validator MUST cross-check each defaultVariants key against the variants keys, and each value against the value-keys of the matching axis.
Check 5 : VariantProps export
PASS if BOTH of the following hold :
- The cva return value is exported (
export { Button, buttonVariants }ORexport const buttonVariants = cva(...)). - The component props type intersects
VariantProps<typeof buttonVariants>directly OR via a type alias.
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(/* ... */)
type ButtonProps =
React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> &
{ asChild?: boolean }
function Button({ variant, size, className, ...props }: ButtonProps) { /* ... */ }
export { Button, buttonVariants }
FAIL if :
// FAIL : variants object not exported ; consumer cannot reuse classes for a link styled as a button
const buttonVariants = cva(/* ... */)
export { Button }
// FAIL : variant union hand-written ; loses type-sync if a variant value is renamed
type ButtonProps = { variant?: "default" | "outline", ... }
// FAIL : VariantProps reference missing entirely ; component accepts the prop but type has no union
type ButtonProps = React.ComponentProps<"button"> & { variant?: string }
REASON : without VariantProps<typeof buttonVariants> the consumer surface is detached from the cva source of truth. Renaming outline to outlined then no longer surfaces as a TypeScript error at the call site, and the runtime simply silently produces no classes.
Check 6 : cn() merge of consumer className
PASS if the component renders className as cn(xVariants({ ..., className })) (the canonical shadcn pattern, passing className as a key of the variants call) OR cn(xVariants({ ... }), className) (the older pattern, passing className as a second argument to cn) :
// PASS : canonical evergreen-2026 pattern
<Comp className={cn(buttonVariants({ variant, size, className }))} {...props} />
// PASS : older but valid pattern (still seen in older shadcn revisions)
<Comp className={cn(buttonVariants({ variant, size }), className)} {...props} />
FAIL on any of these :
// FAIL : template-string concat ; twMerge bypassed, duplicate utilities leak to DOM
<button className={`${buttonVariants({ variant, size })} ${className}`} />
// FAIL : plus-concat ; same problem ; also brittle on undefined className
<button className={buttonVariants({ variant, size }) + " " + (className ?? "")} />
// FAIL : caller className dropped entirely
<button className={buttonVariants({ variant, size })} />
// FAIL : double cn() ; redundant, indicates the author misunderstands the API
<button className={cn(cn(buttonVariants({ variant, size }), className))} />
REASON : cn() is the only path that runs tailwind-merge. Without twMerge a caller className="bg-red-500" and a variant class "bg-primary" both appear in the DOM ; CSS specificity decides arbitrarily by source order. The shadcn convention is that caller className ALWAYS wins.
Check 7 : asChild Slot pattern
If the component accepts asChild?: boolean, PASS only if all three hold :
- The component imports
Slotfromradix-ui(evergreen-2026 unified package) OR from@radix-ui/react-slot(pre-Feb-2026 packages still on the legacy split layout). - The body picks
Slot.Root(unified) orSlot(legacy) whenasChild === true, and the native tag string otherwise. - The same className expression (the
cn(xVariants(...))call) wraps both branches.
import { Slot } from "radix-ui" // evergreen-2026
function Button({ className, variant, size, asChild = false, ...props }: ButtonProps) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
FAIL on any of these :
// FAIL : asChild prop accepted but never branched ; renders <button> regardless
function Button({ asChild = false, ...props }) { return <button {...props} /> }
// FAIL : Slot not imported ; runtime ReferenceError
function Button({ asChild }) { const Comp = asChild ? Slot.Root : "button"; ... }
// FAIL : className applied only to one branch ; asChild path loses variant classes
return asChild ? <Slot.Root {...props} /> : <button className={cn(...)} {...props} />
REASON : the asChild pattern only works because Radix Slot forwards the parent's className, ref, and onClick into the child. Skipping Slot makes asChild non-functional ; applying className to only one branch silently produces unstyled output exactly half the time.
Sample-code-to-verdict mapping (illustrative)
The full table lives in references/methods.md § Sample-code-to-verdict. A few high-yield examples summarised here :
| Symptom | Likely failed check | Verdict pointer |
|---|---|---|
cn() appears INSIDE a variants.variant.default string |
Check 2 | examples.md WRONG-A |
| compoundVariants declared as the first key in the cva config | Check 3 | examples.md WRONG-B |
defaultVariants: { varient: "default" } (typo) |
Check 4 | examples.md WRONG-C |
Consumer className passed but cn not called |
Check 6 | examples.md WRONG-D |
asChild prop in interface but body always renders <button> |
Check 7 | examples.md WRONG-E |
export { Button } without buttonVariants |
Check 5 | examples.md WRONG-F |
Cross-check with downstream skills
This validator alone CANNOT detect runtime issues caused by Tailwind v3-vs-v4 class semantics ; that diagnosis lives in shadcn-errors-styling-conflicts (B11). When Check 6 PASSES but the caller still reports "override does not visually win", redirect the user to that skill for the merge-order debug. The B11 skill enumerates the arbitrary-vs-preset, important-modifier, and v3-vs-v4-rename traps that survive a correct cn() call.
Companion Skills
shadcn-syntax-variant-cva(B3, reference for the underlying API : signature, base, variants, compoundVariants, defaultVariants, VariantProps, evaluation order, asChild Slot, the cn() helper composition). ALWAYS read it BEFORE writing or auditing a cva component for the first time.shadcn-errors-styling-conflicts(B11, debugging for the case where the validator passes all 7 checks but the consumer still sees a visual override failure). Read it AFTER a PASS verdict if the bug persists.shadcn-agents-component-selector(sibling agent : picks the correct shadcn primitive in the first place ; out of scope for this validator).shadcn-impl-extending-components(impl-layer recipe for adding a new variant or compoundVariant to an existing shadcn component without breaking VariantProps inference).
Reference files
references/methods.md: the 7 checks as a numbered enforcement spec plus the sample-code-to-verdict table.references/examples.md: one WRONG cva component with multiple violations and the verdict, one RIGHT cva component verified, and edge cases (variant value as class-array, compoundVariants with multiple match keys, custom variant extension via spread).references/anti-patterns.md: at least six observed cva anti-patterns from real shadcn projects with the verdict and the canonical fix.