4.5 KiB
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: <Link>
wrapping <Button> was producing <a><button>, which is invalid HTML and
announces as two nested controls.
Registry components import @/lib/utils and @/components/ui/*. A workspace
package cannot rely on an app's tsconfig aliases, so those imports are rewritten
to relative paths on the way in. That is a one-line edit per component and is
documented in the @sport/ui barrel.
Two registry defaults had to be overridden, both because they assume a light
page: outline shipped with bg-background, which rendered a white button with
white text on the black hero, and the base sizes are rounded and lowercase where
this brand is square and uppercase. Owning the source is what made those
one-line fixes rather than a fight.
The cost is a larger dependency surface — radix-ui, lucide-react, motion,
embla-carousel-react, tw-animate-css — and a standing obligation to keep
copied components in step with upstream by choice rather than by npm update.
Alternatives considered
Keep hand-rolling everything. Total control, and the existing primitives were good. Rejected once the list of things to build became "focus trap, scroll lock, portal, collision detection" — that is a component library, and writing one is not this project.
Adopt a full component library (MUI, Mantine, Chakra). Faster to start and far harder to make look like anything but itself. The visual direction here is the deliverable, and fighting a theme system to reach it is worse than building the branded parts by hand.
Use shadcn for everything, including the product card and header. This is the common failure mode. It produces a store that works correctly and looks like a demo. The split above exists precisely to prevent it.
Per-app copies of the shadcn layer instead of a shared package. Matches
shadcn's single-app default and lets the two apps drift freely. Rejected because
Button and Input genuinely are identical in both, and two copies means two
places to fix the next bg-background problem.