# ADR-0017: shadcn/ui for infrastructure, hand-built for brand
- **Status:** Accepted
- **Date:** 2026-08-12
## Context
The component layer started as four hand-rolled primitives in `@sport/ui`
(Button, Input, Badge, Skeleton). That was enough while the surface was static
text and links. It stopped being enough the moment real interaction arrived: the
mobile filter panel needs focus trapping and scroll locking, the product editor
needs tabs, row actions need a menu, and the media picker needs a modal.
Writing those by hand means writing focus management, `aria-modal` wiring,
escape handling, portal placement and collision detection. That work is
well-understood, easy to get subtly wrong, and worth nothing to this store's
customers — nobody chooses a running jacket because the dialog traps focus
correctly. They only notice when it doesn't.
The opposite is true of the product card, the header, the hero and the PDP.
Those _are_ the store. A generic card component would make them look like every
other template on the internet, which is precisely the outcome the visual
direction exists to avoid.
## Decision
**Two tiers, split on whether a component carries brand.**
`packages/ui/src/components/ui/` holds shadcn/ui components, owned as source in
this repo and shared by the storefront and the admin: Button, Input, Dialog,
Sheet, DropdownMenu, Tabs, plus Badge and Skeleton. Registry components are
copied in essentially unedited so that anything else from shadcn drops in later.
`apps/storefront/src/components/commerce/` holds everything that carries the
brand, built by hand: hero, mega menu, site header, product card, product
gallery, product detail, filter sheet. These are storefront-only and are never
promoted to `@sport/ui`, per the existing rule in architecture §4.
**A semantic token layer adapts shadcn to the palette.** shadcn writes against
role tokens (`--background`, `--primary`, `--ring`, `--radius`); the store's
palette is `ink`/`volt`. `packages/config/tailwind/theme.css` maps one onto the
other, so a registry component already looks like this store before it is
touched. Note that shadcn's `--accent` is a hover surface, not a brand accent —
it maps to `ink-100`, and volt stays applied deliberately.
**Supporting libraries, each for one job.** Lucide for icons, Motion for the
hero's entrance, Embla for the PDP gallery's swipe.
## Consequences
The dialog/sheet/menu behaviour we would otherwise have written badly is now
correct and not our problem. `asChild` removed a real defect class: ``
wrapping `