Introduction
Five tiers of Woohl components, installed with the shadcn CLI.
The Woohl design system is distributed as a shadcn registry. You install a component with one command and it lands in your repo as source you own — no package to version, no build step, nothing to keep in sync by hand.
bunx --bun shadcn@latest add @woohl/buttonWhy a registry rather than a package
Four apps share this system: the buyer storefront, the seller portal, the admin console, and this site. They are separate repositories on separate release cadences, and for a long time the system was propagated between them by copying a folder. That worked until the copies drifted — one repo fixed a bug locally, the next copy overwrote it, and the diff that was supposed to catch that had become too noisy to read.
A registry keeps the good part of copying (you own the source, you can edit it, nothing is hidden behind a version range) and removes the bad part: the CLI resolves dependencies, rewrites import paths, and installs the npm packages a component needs.
The five tiers
Each tier may depend on the ones above it and never the other way round.
| Tier | What lives there |
|---|---|
| Tokens & brand | Colour, type, spacing, motion, the four product themes, the logo |
| Primitives | cn, cva, structural glyphs, IconSlot, Separator, Skeleton |
| Components | Button, Input, Badge, Avatar, Switch, Checkbox, Radio, Tooltip, Field, Card |
| Composites | Dialog, Drawer, Dropdown, Popover, Select, Tabs, Table, Pagination, Toast, Navigation |
| Blocks | ProductCard, SellerCard, CartItem, Price, Rating and the rest of the marketplace UI |
Installing a block pulls its whole chain. @woohl/product-card brings the
tokens, cn, Badge, Price, Rating and WishlistButton with it, because the CLI
follows registryDependencies for you.
Icons are yours, not ours
Nothing in this system ships an icon set. The buyer app draws Phosphor, the seller and admin portals draw Lucide, and a shared component that picked one would be wrong in two places out of three.
Components that need a mark you chose take it as a prop:
<Button icon={<Icon name="search" />}>Search</Button>The handful of marks a control draws as part of itself — a checkbox's tick, a
dialog's close cross, a select's caret — come from @woohl/glyphs and are not
an icon set you should reach into for anything else.
Typography systems
Two type systems, never mixed on one surface:
- System A (Editorial): Schibsted Grotesk + Playfair Display + JetBrains Mono. For social, campaigns, print, brand pages.
- System B (Product): Nunito Sans + Playfair Display (accent line only). For storefront, app, dashboard, transactional email.
- All four product themes use System B.
- Role restrictions: Baloo Bhaijaan 2 is wordmark/logo only (never layout), Satisfy is one accent word max once per page, Playfair Display is italic accent line/pull quotes only, JetBrains Mono is data/codes only.
- Product scale floor: nothing below 12px ever.
- The signature two-line headline device: line two is Playfair Display italic in the accent, carries the idea not the qualifier.
- Typographic measure:
--measure-body: 68ch,--measure-display: 18ch.
Motion principles
Motion explains, it doesn't perform. Three rules:
- Motion follows cause: something moves because a person did something
- Fast in, slower out: entrances are quick, exits can take longer
- One thing at a time: stagger 60ms apart, reading order, max 6 items
- Duration scale: Instant (120ms hover), Fast (180ms buttons/toggles), Base (240ms dropdowns/accordions), Slow (320ms modals/drawers), Deliberate (480ms onboarding/celebration only)
- Three easings:
ease-out-woohl(entries),ease-in-out-woohl(state changes),ease-spring(celebrations, rare) linearis for progress bars and spinners only- Animate transform and opacity only — never width, height, top, left (forces layout, drops frames)
prefers-reduced-motionhandled globally once; progress/loading indicators keep animating
Component design rules
- Button hierarchy: 5 levels of emphasis (primary, secondary, ghost, danger, inverse, link). One primary per view. Crimson danger never confused with brand accent buy.
- Whitespace-first layout: Let space carry hierarchy. A divider earns its place only when the gap alone reads as 'same group, just spaced out'.
- Switch vs Checkbox: Switch takes effect immediately. Checkbox is a value you submit. 'Email me about drops' in auto-save settings = switch; same line in checkout form with Continue button = checkbox.
- Tooltip vs Popover vs Dropdown: Tooltip = hint (supplementary,
aria-describedby). Popover = panel (forms, rich content). Dropdown = menu of actions. - Dialog vs Drawer: Dialog = question you must answer. Drawer = place you can stay (bag, filters, detail view).
- Toast vs Alert: Toast = ephemeral confirmation (4s auto-dismiss). Alert = persistent in-page message. Errors never auto-dismiss.
- Select vs NativeSelect: Custom Select for styled dropdowns. NativeSelect for long lists on mobile (OS picker is genuinely better).
- Color is never the only signal: ~1 in 12 men has color-vision deficiency. Status always carries a word + optional icon shape.
Accessibility standards
- Focus ring: 2px outline at 2px offset for clickable controls; brand border + soft ring for form fields
- Touch targets: 44px WCAG 2.2 minimum, enforced via invisible padding on small controls
- Router-agnostic navigation:
asprop for framework links,aria-current="page"for current page - SkipLink as first tab stop on every page
- Form fields: label association (
htmlFor/id), hint/error announcement (aria-describedby), invalid state (aria-invalid) - Error copy: prescriptive ('Enter a 6-digit PIN code'), never generic ('Invalid input')