shadcn ui Core: Stack Composition
shadcn ui is not a runtime component library : it is a CLI that copies TypeScript source files into your project. Once shadcn add button finishes, your project owns components/ui/button.tsx outright, and the only things that remain at runtime are the underlying primitives (Radix, cva, tailwind-merge, clsx, lucide-react, Tailwind). Understanding this stack is the foundation for every other shadcn skill.
Quick Reference
Stack layers (every shadcn project has these)
| Layer : owns : package : runtime cost |
|---|
Headless behaviour + a11y : focus-trap, ARIA, keyboard, RTL, controlled state : radix-ui (unified, Feb 2026) or @radix-ui/react-* (legacy per-component) : small (component-scoped, tree-shaken) |
Variant API : type-safe variant + size composition : class-variance-authority (cva) : tiny (~1 KB gzipped) |
Class conflict resolution : twMerge('px-2 p-3') returns p-3, dropping px-2 : tailwind-merge v3.x (Tailwind v4) or v2.x (Tailwind v3) : ~6 KB gzipped, cached per call site |
Conditional class composition : clsx('a', cond && 'b', { c: enabled }) : clsx : ~250 bytes |
Utility CSS : every visible style : tailwindcss v3.4 or v4.0+ : build-time |
Icons : default icon set : lucide-react : tree-shaken per icon |
Top-3 most-used patterns
// 1. The cn() helper : single merge primitive used by every shadcn component.
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
// 2. A cva-driven Button (the canonical shadcn pattern).
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md text-sm font-medium",
{
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",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "size-9",
},
},
defaultVariants: { variant: "default", size: "default" },
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> { asChild?: boolean }
export function Button({ className, variant, size, ...props }: ButtonProps) {
return <button className={cn(buttonVariants({ variant, size }), className)} {...props} />
}
// 3. A Radix-wrapped Dialog : shadcn-copied component owns the styling,
// Radix owns the behaviour.
import * as DialogPrimitive from "@radix-ui/react-dialog" // or: from "radix-ui"
import { cn } from "@/lib/utils"
export const Dialog = DialogPrimitive.Root
export const DialogTrigger = DialogPrimitive.Trigger
export const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/80" />
<DialogPrimitive.Content
ref={ref}
className={cn("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 ...", className)}
{...props}
/>
</DialogPrimitive.Portal>
))
Decision Trees
Picking which layer solves your problem
need to ...
├── make a component a11y, focus-trapped, keyboard-navigable, ARIA-compliant?
│ └── ALWAYS use the Radix primitive (via shadcn-wrapped component)
├── add a typed variant prop (`variant="destructive"`, `size="lg"`)?
│ └── ALWAYS use cva + VariantProps ; NEVER inline if/switch on className
├── compose className conditionally (`cn('base', isOpen && 'rotate-180')`)?
│ └── ALWAYS use cn() ; clsx handles the conditional, twMerge resolves conflicts
├── allow caller to override styles (`<Button className="rounded-none" />`)?
│ └── ALWAYS pass caller's className LAST to cn() so twMerge keeps it
├── add an icon?
│ └── ALWAYS use lucide-react (`import { Check } from "lucide-react"`) unless migrated
├── style something visible?
│ └── ALWAYS Tailwind utility classes ; NEVER inline `style={...}` for design tokens
└── render dynamic content with no Radix equivalent (Alert, Card, Badge, Breadcrumb)?
└── pure cva + Tailwind (no Radix), still composed via cn()
Picking between radix-ui unified vs @radix-ui/react-* legacy
new project after Feb 2026?
├── ALWAYS install the unified package : `npm install radix-ui`
└── Import per-primitive : `import { Dialog as DialogPrimitive } from "radix-ui"`
existing project on @radix-ui/react-*?
├── keep `@radix-ui/react-dialog`, `@radix-ui/react-dropdown-menu`, etc.
└── Imports unchanged : `import * as DialogPrimitive from "@radix-ui/react-dialog"`
mixed install (some unified, some per-component)?
└── NEVER mix in the same project ; pick one and migrate fully
Picking tailwind-merge version
project Tailwind version?
├── Tailwind v3.4 (or older) : `tailwind-merge@^2.6.0`
├── Tailwind v4.0 through v4.3 : `tailwind-merge@^3.0.0`
└── mismatch produces silent merge failures on new v4 utilities (e.g. `shadow-xs`)
Picking cva 0.7 stable vs 1.0 beta
production app, want stability?
├── `class-variance-authority@^0.7.1` (stable)
want latest variant features (slot-typed compoundVariants, recipe inheritance)?
├── `class-variance-authority@^1.0.0-beta` (beta as of May 2026)
└── beta API is mostly source-compatible ; review migration notes in cva changelog
Patterns
Pattern 1: The cn() helper
Every shadcn project ships lib/utils.ts with this exact function. NEVER replace it with raw string concatenation.
// lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
What each call does :
// clsx handles conditionals + falsy values + objects
clsx("base", isOpen && "rotate-180", { active: enabled })
// => "base rotate-180 active"
// twMerge handles Tailwind utility conflicts
twMerge("px-2 py-1 bg-red-500", "p-4 bg-blue-500")
// => "p-4 bg-blue-500"
// cn combines both
cn("px-2 py-1 bg-red-500", isPrimary && "p-4 bg-blue-500", className)
// when isPrimary === true and caller passes className="rounded-full" :
// => "p-4 bg-blue-500 rounded-full"
Pattern 2: cva variant signature with TypeScript inference
import { cva, type VariantProps } from "class-variance-authority"
const buttonVariants = cva(
// base classes : ALWAYS apply
"inline-flex items-center justify-center rounded-md text-sm font-medium",
{
// variants : one prop per variant family
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",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 px-3 text-xs",
lg: "h-10 px-8",
icon: "size-9",
},
},
// compoundVariants : combinations that need extra classes
compoundVariants: [
{ variant: "outline", size: "sm", class: "border-2" },
],
// defaultVariants : applied when prop is `undefined` (NOT empty string)
defaultVariants: { variant: "default", size: "default" },
}
)
// VariantProps extracts the literal-union prop types from cva()
type ButtonVariantProps = VariantProps<typeof buttonVariants>
// => { variant?: "default" | "destructive" | "outline" | "ghost" | null,
// size?: "default" | "sm" | "lg" | "icon" | null }
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
ButtonVariantProps { asChild?: boolean }
ALWAYS call cva once at module scope (not inside the component), so the function and types are constant.
Pattern 3: Caller override semantics
// Component definition : caller's className passed LAST to cn()
export function Button({ className, variant, size, ...props }: ButtonProps) {
return (
<button
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
// Usage : caller wins on conflicting utilities
<Button variant="default" className="rounded-none px-12" />
// => "inline-flex items-center justify-center text-sm font-medium
// bg-primary text-primary-foreground hover:bg-primary/90 h-9 py-2
// rounded-none px-12"
// (rounded-md and px-4 from variants are dropped by twMerge)
If className came FIRST inside cn(), the variant classes would override the caller : exactly the opposite of the intended ergonomics.
Pattern 4: Radix primitive wrapping (shadcn convention)
// components/ui/dialog.tsx (shadcn-copied)
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog" // legacy
// or: import { Dialog as DialogPrimitive } from "radix-ui" // unified
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn("fixed inset-0 z-50 bg-black/80", className)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
export { Dialog, DialogTrigger, DialogPortal, DialogClose, DialogOverlay /* etc */ }
ALWAYS import the shadcn-wrapped component from @/components/ui/dialog, NOT the Radix primitive directly. Importing Radix bypasses all of the project's styling.
Pattern 5: Lucide icon usage inside shadcn components
import { Check, ChevronRight, Loader2 } from "lucide-react"
import { cn } from "@/lib/utils"
export function CheckIcon({ className }: { className?: string }) {
return <Check className={cn("size-4", className)} />
}
Tree-shaken : only the icons you actually import end up in the bundle. The default size used by shadcn components is size-4 (16px). Animated states use the spinning Loader2 icon with animate-spin.
Reference Links
references/methods.md: complete API signatures (cva, VariantProps, twMerge, clsx, cn, Radix primitive list with import paths).references/examples.md: working examples (Button with cva, Dialog wrapping Radix, cn merge cases, lucide icon imports).references/anti-patterns.md: common mistakes (raw className concatenation, missing twMerge, bypassing shadcn-wrapped components, wrong cva variant ordering, mixing radix-ui unified with @radix-ui/react-*).
Verified Sources
- https://ui.shadcn.com/docs (open-code + composition + ownership model)
- https://www.radix-ui.com/primitives (Radix primitives, unified
radix-uipackage since Feb 2026) - https://www.radix-ui.com/primitives/docs/overview/getting-started (
npm install radix-ui,import { Popover } from "radix-ui") - https://cva.style/docs (cva signature, current 1.0 beta)
- https://github.com/dcastil/tailwind-merge (twMerge API, v3.x for Tailwind v4, v2.x for Tailwind v3)
- https://github.com/shadcn-ui/ui (shadcn source code for canonical patterns)
Last verified : 2026-05-19 (shadcn evergreen-2026 / canary).