# Design System Construction

> Build or audit a Tailwind + cva + shadcn/ui design system with two-tier tokens, variants, theming, and 4-state Storybook stories. Use when starting a new project, addressing UI inconsistency, onboarding a designer, adding dark mode / theming, when arbitrary values (w-[347px]) accumulate, or when component patterns repeat across 2+ places. Not for component-level extraction decisions (use component-quality) or WCAG conformance auditing (use accessibility-audit).

- Skill: `jaykim88/design-system-construction` (Agent Skill)
- Install (CLI): `npx skillmds@latest add jaykim88/design-system-construction`
- Raw SKILL.md: https://api.skillmd.com/api/skills/jaykim88/design-system-construction/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- License: MIT
- Author: JayKim88 (https://skillmd.com/u/jaykim88)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/jaykim88/design-system-construction

---


# 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

1. **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)

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 `.dark` class or `prefers-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)

2. **Apply a typed variant system** when a component has 3+ variants
   - Define `size` / `intent` / `state` axes
   - Use a class-merge helper for conditional class composition (last-wins on conflicts)
   - Document default variants explicitly
   - (cva + `cn()` — see Implementation)

3. **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 / `asChild` slot (render-as-another-element — e.g. a Button rendering an `<a>`), `className` passthrough merged last-wins, forwarded refs/props
   - (shadcn CLI `npx shadcn@latest add …`; Radix `Slot` / `asChild` — see Implementation)

4. **Write 4-state stories in a component workshop; run an a11y check on each**
   - `Default` — happy path
   - `Loading` — pending / skeleton
   - `Error` — invalid input or failed fetch
   - `Empty` — no data
   - Run an automated a11y check on every story
   - (Storybook + a11y addon — see Implementation)

5. **Eliminate arbitrary values**
   - Scan codebase (broader regex covers spacing, sizing, layout, color):
     ```bash
     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

6. **Document the system**
   - `COMPONENTS.md` listing shared components and their usage
   - Variant matrix per component

7. **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**

```tsx
// ❌ 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 `density` to all buttons) — coordinate with designer
- **Before replacing existing components** with shadcn/ui equivalents — visual regression risk, designer review needed

## Output
- **Tokens**: `tailwind.config.ts` `theme.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.tsx` per component, 4 states (default / loading / error / empty) where applicable
- **Inventory**: `docs/COMPONENTS.md` with 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 reference `bg-primary`, never `bg-blue-600`
- Theming: `.dark` class strategy with paired `:root` / `.dark` CSS-variable blocks; resolve theme in an inline `<head>` script to avoid FOUC (`next-themes` handles 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 toast` copies into `components/ui/` (no npm dependency)
- Polymorphism: Radix `Slot` via the `asChild` prop — 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-specific `tailwind-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 system
- `accessibility-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 `*`/`*-foreground` pairs 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.

