Skip to main content
AC
Build & Implementation5.7 KBMIT licensed

shadcn-component-architecture

Rewritten from patterns in shadcn-ui/ui and radix-ui/primitives (MIT)

Build and extend UI with the shadcn/ui copy-paste registry pattern on Radix primitives, cva variants, and CSS-variable tokens. Use when scaffolding a component library, adding or theming shadcn components, wiring design tokens, or extending variants. Not for auditing token drift across an existing system: use design-system-drift. Not for responsive layout strategy: use mobile-first-responsive.

  • shadcn
  • component
  • architecture

SKILL.md

shadcn Component Architecture

shadcn/ui is a pattern, not a dependency. Component source is copied into your repo, so props, variants, and types are read from real files instead of guessed from an unseen npm package. That kills prop hallucination and version drift. Every component is three layers: a Radix primitive (behavior, a11y), Tailwind classes (styling), and cva (typed variants).

Setup and Adding Components

bunx shadcn@latest init         # components.json, cn(), CSS vars, tailwind mapping
bunx shadcn@latest add button dialog dropdown-menu form

The CLI writes source into components/ui/. It also installs from any registry URL, which is how ecosystem registries (Magic UI, Aceternity) ship:

bunx shadcn@latest add "https://<REGISTRY_URL>/r/<component>.json"

Rule one: before using or changing any added component, open components/ui/<name>.tsx and read it. The contract is right there. Never guess props.

The Three Layers

  1. `cn()` helper. clsx for conditionals plus tailwind-merge so later utilities win and consumer overrides actually apply. Always merge className last: cn(baseClasses, className).
  2. CSS-variable tokens. Colors live as HSL channel values in :root and .dark; Tailwind maps semantic names (background, foreground, primary, border, ring) to hsl(var(--token)). Components use semantic classes (bg-background, text-muted-foreground), never raw palette values (bg-slate-900). Restyle the app by editing tokens, never by editing components.
  3. cva variants. Base classes plus a typed variant map with defaultVariants. To add a variant or size, extend the cva map. Never fork a parallel component. To change behavior, work with the Radix primitive, not by intercepting DOM events.

Read references/component-anatomy.md for the full Button implementation, the cn() source, and the token CSS plus Tailwind mapping to copy.

Composition Rules

  • `asChild` / `Slot`: merge props onto a child instead of adding a wrapper. <Button asChild><Link href="/x">Go</Link></Button> keeps button styling on a router link.
  • Compound components: Radix ships parts (Dialog, DialogTrigger, DialogContent, DialogTitle). Always include the a11y parts (DialogTitle, DialogDescription), visually hidden with sr-only if needed.
  • Controlled vs uncontrolled: prefer uncontrolled (defaultOpen) unless parent state must drive the primitive, then pass open plus onOpenChange.
  • Forms: react-hook-form plus zod through the shadcn Form wrapper so labels, validation, and errors stay wired and accessible.

Avoiding the Generic AI Look

  • No unmotivated purple-indigo gradients, oversized glassy cards, or centered-everything heroes.
  • Derive spacing, radius, and shadow from tokens; one consistent --radius and a real type scale read as designed.
  • One strong accent from the token set beats rainbow gradients.
  • Vary density with intent. Real products are not uniformly p-8 gap-6.

Verification

Run grep -rnE "bg-(slate|gray|zinc|blue|indigo|purple)-[0-9]" components/ app/. Expect zero matches in component code. Any hit is a hardcoded palette value: replace it with a semantic token class.

Toggle the .dark class on <html> and render the key screens. Expect every surface, border, and text color to flip with readable contrast. Anything that stays light is bypassing the token system: find the raw color and fix it.

Pass a conflicting className to a component (<Button className="h-14">). Expect the override to win. If it does not, className is not merged last through cn().

Good vs Bad

Bad: A new "destructive outline" button is needed, so a DangerButton.tsx gets created with copied markup and bg-red-600 hardcoded. Now there are two button contracts, and dark mode breaks on one of them.

Good: Add a destructive-outline entry to the existing buttonVariants cva map using border-destructive text-destructive token classes. One component, typed variant, both themes work.

Footguns

  • Guessed props on a component you never opened. The source is in the repo; the compile error is self-inflicted. Fix: read components/ui/<name>.tsx before use.
  • Missing Radix a11y parts. A DialogContent without DialogTitle throws warnings and fails screen readers. Fix: include title and description, sr-only when not shown.
  • `className` merged early. Base classes clobber consumer overrides. Fix: cn(variants(), className) with className last.
  • Editing generated component files casually, then re-running `add`. The CLI can overwrite local edits. Fix: treat components/ui/ as owned code; re-adding a component is a deliberate, diffed decision.

Completion Checklist

  • [ ] Component source exists in components/ui/ and was read before use
  • [ ] All colors are semantic token classes, grep for raw palette values comes back clean
  • [ ] className merged last through cn(), override test passes
  • [ ] Light and dark both render with holding contrast
  • [ ] Radix a11y parts present, keyboard and focus ring work
  • [ ] New variants live in the cva map, no forked components

Any box unchecked: not done. Fix or say so.

Reference files

More in Build & Implementation

All skills