Design System Construction
Purpose
Establish a reusable component library and design tokens so that UI consistency is enforced mechanically (via tokens and variants) rather than relied on by convention.
Universal — design tokens (colors, typography, spacing, radius), variant systems, 4-state stories (default/loading/error/empty), and "no arbitrary values" discipline apply to any UI framework. The default Procedure illustrates them with the Tailwind + cva + shadcn/ui idiom; the Other stacks section maps each to shadcn-vue / shadcn-svelte / spartan-ng and CSS-in-JS alternatives.
Procedure
- Define design tokens in two tiers
- Primitive tier — the raw palette/scale (
blue-500,gray-900, spacing units). Internal; components never reference these directly. - Semantic tier — purpose-named aliases that reference primitives:
primary,background,border,destructive,muted. Components consume only semantic tokens — this is what makes theming work: you remap the semantic→primitive layer once instead of editing every component. - Pair every surface token with a foreground (
primary/primary-foreground) and verify each pair meets WCAG contrast at definition time — contrast is a token-design decision, not a per-component audit deferred to later. - Also: typography scale (font-family / size / line-height), spacing scale (4 / 8px base), radius (
sm/md/lg/full) as named tokens. - (CSS vars in
:root— see Implementation)
- Primitive tier — the raw palette/scale (
1b. Make theming a token-map swap, not a component concern
- Define the light and dark semantic maps — same semantic names, different primitive values; switch via a
.darkclass orprefers-color-scheme - Because components reference only semantic tokens (step 1), adding dark mode needs zero component edits
- On SSR, resolve the theme before first paint (inline script in
<head>) to avoid a flash of the wrong theme (FOUC)
Apply a typed variant system when a component has 3+ variants
- Define
size/intent/stateaxes - Use a class-merge helper for conditional class composition (last-wins on conflicts)
- Document default variants explicitly
- (cva +
cn()— see Implementation)
- Define
Adopt a component-primitive library you own the code of for common primitives (Button, Dialog, Form, Select, Toast)
- Copy-in, not an npm dependency — the source lands in your repo (e.g.
components/ui/) so you can modify it freely - You own the code, not a library — no version-lock, no upstream override fights
- A CLI that copies components in (and wires deps/config) beats manual copy-paste
- Make primitives extensible without forking: a polymorphic /
asChildslot (render-as-another-element — e.g. a Button rendering an<a>),classNamepassthrough merged last-wins, forwarded refs/props - (shadcn CLI
npx shadcn@latest add …; RadixSlot/asChild— see Implementation)
- Copy-in, not an npm dependency — the source lands in your repo (e.g.
Write 4-state stories in a component workshop; run an a11y check on each
Default— happy pathLoading— pending / skeletonError— invalid input or failed fetchEmpty— no data- Run an automated a11y check on every story
- (Storybook + a11y addon — see Implementation)
Eliminate arbitrary values
- Scan codebase (broader regex covers spacing, sizing, layout, color):
grep -rE '\b(w|h|min-w|min-h|max-w|max-h|p[xytrbl]?|m[xytrbl]?|gap|top|left|right|bottom|text|bg|border|fill|stroke|grid-cols|grid-rows)-\[' src/ - Replace each hit with a token or compound variant
- Document the exception when an arbitrary value is genuinely necessary
- Scan codebase (broader regex covers spacing, sizing, layout, color):
Document the system
COMPONENTS.mdlisting shared components and their usage- Variant matrix per component
Verify (validation loop)
- Re-run the arbitrary-value grep (step 5); loop until 0 (documented exceptions aside)
- Confirm every semantic surface/foreground pair meets WCAG contrast in both light and dark maps
- Toggle dark mode and confirm no component needed a per-component override (else a component is referencing a primitive — fix step 1)
Severity tiers
| Tier | Examples | Action SLA |
|---|---|---|
| Critical | A semantic color pair fails WCAG contrast (ships an inaccessible default); components reference primitive/raw values (blue-500) so dark mode is broken |
Block release; fix immediately |
| Major | Arbitrary values (w-[347px]) across many files; 3+ variants hand-rolled without a typed variant system; async component with no loading/error/empty story |
Fix this sprint |
| Minor | Missing story for a static component; undocumented default variants; token naming drift | Schedule within 2 sprints |
Before / After
Arbitrary value vs token + variant
// ❌ Arbitrary values — bypasses design system, untyped, hard to enforce consistency
<button className="w-[347px] h-[44px] bg-[#1a73e8] text-[14px] px-[16px]">
Save
</button>
// ✅ Tokens + cva variant — typed, consistent, override-friendly
const buttonVariants = cva('inline-flex items-center justify-center', {
variants: {
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-6 text-lg' },
intent: { primary: 'bg-primary text-primary-foreground', ghost: 'bg-transparent' },
},
defaultVariants: { size: 'md', intent: 'primary' },
});
<button className={cn(buttonVariants({ size: 'md', intent: 'primary' }), className)}>
Save
</button>
Completion Criteria
- Arbitrary Tailwind values = 0 (documented exceptions only)
- Components reference semantic tokens only — no primitive/raw values (
blue-500) in component code - Every semantic color pair (
*/*-foreground) meets WCAG AA contrast in both light and dark maps - All shared components have Storybook stories
- 4-state story coverage = 100% for components with async data
- CVA variants used for any component with 3+ visual variations
- Tokens defined as CSS variables for theme switching
Stop & Ask (AI must pause for user approval)
- Before migrating arbitrary values (
w-[347px]) across 10+ files — design intent may require slightly different tokens - Before introducing a new cva variant axis (e.g., adding
densityto all buttons) — coordinate with designer - Before replacing existing components with shadcn/ui equivalents — visual regression risk, designer review needed
Output
- Tokens:
tailwind.config.tstheme.extend+ CSS variables in:root(one block per theme: light, dark) - Component library:
components/ui/with shadcn-style primitives, one file per component - Stories:
<Component>.stories.tsxper component, 4 states (default / loading / error / empty) where applicable - Inventory:
docs/COMPONENTS.mdwith table — component name / variants / file path / Storybook link - Migration log (paste into PR description): arbitrary values eliminated count, components migrated
Implementation
React + Next.js (default)
- Tokens: primitive scale in Tailwind
theme.extend; semantic aliases as CSS variables in:root(e.g.--primary: var(--blue-600)) so components referencebg-primary, neverbg-blue-600 - Theming:
.darkclass strategy with paired:root/.darkCSS-variable blocks; resolve theme in an inline<head>script to avoid FOUC (next-themeshandles this on App Router) - Variants:
class-variance-authority(cva) - Class merging:
cn()helper (clsx+tailwind-merge) - Component library: shadcn/ui via
npx shadcn@latest add <component>(you own the code); e.g.npx shadcn@latest add button dialog form select toastcopies intocomponents/ui/(no npm dependency) - Polymorphism: Radix
Slotvia theasChildprop — shadcn primitives already expose it - Stories: Storybook +
@storybook/addon-a11y
Other stacks
- Vue / Nuxt: Tailwind tokens identical; variants via
cva(works in Vue too) or vue-specifictailwind-variants; component library:shadcn-vue(port of shadcn/ui); Storybook with Vue 3 integration - SvelteKit: Tailwind tokens identical;
tailwind-variants(cva-like API for any framework); component library:shadcn-svelte; Storybook 7+ supports Svelte - Angular: Tailwind tokens identical; CSS variables + Angular component library (e.g., spartan/ui — shadcn-like for Angular); Storybook supports Angular
- CSS-in-JS alternatives: Vanilla Extract, Stitches, Panda CSS — all support token-based design systems with type safety
- Universal anti-pattern: arbitrary values (
w-[347px],text-[#1a2b3c]) — every framework that uses Tailwind suffers this; the grep pattern is framework-agnostic
Related skills
component-quality— extraction criteria for promoting components into the design systemaccessibility-audit— design system must meet WCAG 2.2 AA out of the box
Reference
- Key insight encoded: Split tokens into a primitive tier (raw palette) and a semantic tier (
primary,background, …) that aliases it — components reference only semantic tokens, so theming/dark-mode is a one-map swap instead of a per-component edit. Bake WCAG contrast into the*/*-foregroundpairs at token-definition time. Variants belong in CVA (typed and composable); arbitrary values are a smell — promote to tokens or compound variants. The "ban arbitrary values" rule is a community practice (Vercel/Infinum handbooks), not in official docs — encode it as a project rule.