---
name: shadcn-component-architecture
description: 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.
license: MIT
metadata:
  author: TechTide AI (Alex Cinovoj)
  provenance: rewritten from patterns in shadcn-ui/ui and radix-ui/primitives (MIT)
  category: Build & Implementation
---

# 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

```bash
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:

```bash
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.
