Files
web_sport/docs/adr/0017-shadcn-for-infrastructure-hand-built-for-brand.md
T
2026-08-13 23:20:22 +07:00

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.