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 formThe 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
- `cn()` helper.
clsxfor conditionals plustailwind-mergeso later utilities win and consumer overrides actually apply. Always mergeclassNamelast:cn(baseClasses, className). - CSS-variable tokens. Colors live as HSL channel values in
:rootand.dark; Tailwind maps semantic names (background,foreground,primary,border,ring) tohsl(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. - 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 withsr-onlyif needed. - Controlled vs uncontrolled: prefer uncontrolled (
defaultOpen) unless parent state must drive the primitive, then passopenplusonOpenChange. - Forms:
react-hook-formpluszodthrough the shadcnFormwrapper 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
--radiusand 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>.tsxbefore use. - Missing Radix a11y parts. A
DialogContentwithoutDialogTitlethrows warnings and fails screen readers. Fix: include title and description,sr-onlywhen not shown. - `className` merged early. Base classes clobber consumer overrides. Fix:
cn(variants(), className)withclassNamelast. - 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
- [ ]
classNamemerged last throughcn(), 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.