Le Quang Lam
← All writing

Design system setup

June 27, 2026·8 min read·software

A step-by-step playbook for bootstrapping a design system in a new project. The goal: a foundation that is easy to maintain, easy to extend, and cheap to rebrand.

Mental model — token tiers

Before touching code, internalize the three-tier token hierarchy. Every decision below maps to one of these tiers:

TierExampleChanges when…
Primitive--gray-500, --brand-600Almost never. Raw palette.
Semantic--background, --primary, --borderYou rebrand or remap roles.
Component--button-background, --card-shadowA specific component needs override.

When you decide to "swap the entire color palette", you only edit the primitive tier. When you decide "primary should now be teal instead of black", you only edit the semantic tier. Components never reference primitives directly — that's the rule that makes rebrands cheap.

Phase 1 — Foundation

Step 1. Write design principles (before any code)

Spend 15 minutes answering, in writing:

  • Personality: editorial / playful / corporate / brutalist? → drives font choice, radius, motion intensity.
  • Density: airy or compact? → drives spacing scale.
  • Contrast level: muted or bold? → drives color range and border strength.
  • Motion appetite: still or lively? → drives duration scale and easing choice.

Step 2. Define primitive color tokens

Create the raw palette. Don't name anything by function yet.

--gray-50, --gray-100, ..., --gray-950
--brand-50, --brand-100, ..., --brand-900
(optional) --accent-*, --success-*, --warning-*, --danger-*

Color palette example

Source: Radix Colors

Why OKLCH over hex/HSL: OKLCH matches how human eyes perceive brightness, so hand-built ramps look evenly spaced. Hex and HSL don't, and ramps drift.

Why ramps, not individual colors: you'll always need a hover state, a disabled state, a subtle background.

Done when:

  • Gray ramp has at least 9 steps spanning near-white to near-black.
  • Brand ramp has at least 5 steps.
  • Steps look evenly spaced when laid out side by side.

Step 3. Define semantic color tokens

Map primitives to roles:

--background       → gray-50
--foreground       → gray-950
--muted            → gray-100
--muted-foreground → gray-500
--border           → gray-200
--ring             → gray-400
--primary          → gray-950 (or brand-600)
--primary-foreground → gray-50
--destructive      → danger-500

Why: components reference these names, not primitives. The paired *-foreground tokens guarantee every surface has a readable text color.

Done when:

  • Every primitive used in semantic mapping is consciously chosen, not "looks fine".
  • Each background-ish token has a paired foreground.
  • No component you plan to build needs a color that isn't in this list.

Step 4. Define non-color tokens (in one pass)

Set up the full scaffold, even tokens you won't use today:

--radius (base) + scale: --radius-sm/md/lg/xl/2xl
--shadow-xs/sm/md/lg/xl
--duration-fast/base/slow      (e.g. 140ms / 250ms / 400ms)
--ease-standard/in/out/expo
--z-base/dropdown/sticky/overlay/modal/toast
--tracking-tight/normal/wide/widest

Why upfront: tokens added later tend to be one-off magic numbers in component files, never promoted to the system. Defining the scaffold once forces consistency from the first component you build.

Why derive radius from a single base (--radius-md = calc(var(--radius) * 0.8)): changing one number reshapes the whole UI. Useful when reconsidering personality (sharp → soft).

Done when:

  • All five token families above exist.
  • No component will need to invent its own duration, shadow, or z-index.

Step 5. Mirror everything for dark mode

In a .dark scope, override only the semantic tier. Primitives stay the same.

Why: dark mode is a remapping problem, not a recoloring problem. The same --gray-200 exists in both modes; it just plays the role of --border in light and something else in dark.

Done when:

  • A test page renders correctly in both modes with no hardcoded colors.
  • Toggling .dark on <html> flips the whole UI with zero component changes.

Phase 2 — Typography

Step 6. Choose fonts and define the type scale

Pick:

  • 1 sans (UI, body)
  • 1 serif or display (headings, optional but adds personality)
  • 1 mono (code, fallback to system stack is fine)

Define:

--text-xs / sm / base / lg / xl / 2xl / 3xl / 4xl
--leading-tight / snug / normal / loose

Why a modular scale (ratio ~1.2–1.25): scales built on a ratio look harmonious; ad-hoc sizes (13px, 15px, 17px, 19px) look noisy. Pick a ratio that matches your density principle — tighter ratio (1.125) for compact UI, wider (1.333) for editorial.

Why leading is separate from size: line-height doesn't scale linearly with font-size. Body text wants 1.61.75; display text wants 1.11.25. Coupling them prevents bad pairings.

Done when:

  • A sample page with <h1> through <h4> and 2 paragraphs reads comfortably without per-element class overrides.
  • Code blocks render in mono with subtle background.
  • Selection color is set (small detail, big polish signal).

Step 7. (Optional) Prose styles for long-form content

If the project has blog/MDX/markdown:

  • Create a .prose class with max-width (~65–75ch), tuned line-height, spaced headings, styled blockquotes, lists, hr, code.
  • Keep it in a separate file (prose.css) — it grows fast and clutters globals.css.

Why a separate class: prose rules are aggressive (they style raw <p>, <ul>, etc.). Scoping them to .prose prevents leaking into UI components that also use those tags.

Done when:

  • A sample article renders with comfortable rhythm.
  • No prose rule affects UI outside .prose containers.

Phase 3 — Components

Step 8. Install shadcn primitives — only what you need now

Resist installing everything. Add as you go. Common starter set: button, card, separator, dialog, tooltip, sheet, sonner, skeleton, badge, avatar, accordion.

Why on-demand: shadcn copies source into your repo. Unused components rot — they reference tokens you've since renamed, accumulate dependencies, and add noise during search.

Done when:

  • Every installed primitive is used somewhere within the first week of building.

Step 9. Build layout primitives

Create reusable, unstyled-but-spaced wrappers:

  • Shell / Container — max-width + horizontal padding
  • Stackflex flex-col gap-*
  • Clusterflex flex-wrap gap-*
  • Grid — preset grid templates
  • Section — vertical rhythm wrapper, often with <Separator /> integration

Why: 80% of layout code is the same five patterns. Naming them removes a class-string-soup that would otherwise repeat in every page. Composition becomes readable: <Section><Stack gap="lg">...</Stack></Section>.

Why flex-first, not margin: margins collapse, leak through containers, and create spacing that depends on sibling order. Flex gap is predictable and only affects siblings inside the flex container.

Done when:

  • A feature page can be assembled without writing flex flex-col gap-* strings inline.
  • No layout primitive carries color/typography — only structure.

Step 10. Build shared domain primitives

Small, brand-flavored components used in multiple pages: Kicker, Tag, ArrowLink, BackLink, etc.

Why separate from ui/: shadcn ui/ components are generic and may be regenerated. Shared primitives are yours — they encode brand voice (kicker style, link arrow animation). Mixing them breaks the mental model.

When to promote something to shared: it appears in 2+ pages with the same shape. Until then, leave it inline.

Done when:

  • Each shared primitive accepts a className override (always last argument to cn()).
  • No shared primitive hardcodes layout context (e.g. mt-8). Spacing is the caller's job.

Step 11. Build feature components

Page-specific compositions: Header, Footer, HeroIntro, NowSection, etc. These compose Phase 3 layers and are allowed to be opinionated.

Why this layer is the last: building features before primitives means inventing primitives reactively, creating duplication. Building primitives without features means over-engineering. Phases 1–2 must be solid; Phase 3 layers build bottom-up.

Done when:

  • No feature component reaches for an arbitrary value that isn't a token.
  • Adding a new page reuses existing primitives ≥80% of the time.

Phase 4 — Documentation & Tooling

Step 12. Create a living /design route

Build an in-app page (or Storybook, if team >1) that renders:

  1. Color tokens — swatches for every semantic token, side-by-side light/dark.
  2. Type scale — sample text at every size, with leading.
  3. Spacing, radius, shadow — visual chips for each step.
  4. Components — every primitive and shared component, all states (default/hover/disabled/loading/dark).

Why in-app over static docs: it's real code referencing real tokens. It can't drift. Static markdown documenting hex codes will be wrong within a month.

Why this page pays off: when adding a new component, you check /design to see what tokens exist. When debugging visual inconsistency, you compare against /design. When onboarding someone, you send the link.

Done when:

  • /design exists and is linked from a dev-only menu (or simply bookmarked).
  • Every token has a visual representation on the page.
  • Every shared component appears with at least its default state.

Step 13. Set up lint and formatting

  • ESLint + eslint-plugin-tailwindcss — enforces class ordering, flags contradictions and arbitrary values that have canonical equivalents.
  • Prettier + prettier-plugin-tailwindcss — auto-sorts classes on save.
  • TypeScript strict mode.
  • (Optional) Stylelint for any handwritten CSS.

Why: discipline is fragile. A linter that runs on save catches drift before it ships. Without it, "canonical syntax" becomes a rule that's followed for two weeks and forgotten.

Done when:

  • Saving a file auto-sorts Tailwind classes.
  • An arbitrary value with a canonical equivalent (p-[16px] vs p-4) triggers a warning.

Step 14. Write a CONTRIBUTING.md (or CLAUDE.md / AGENTS.md)

Document the conventions that aren't obvious from reading code:

  • cn() usage and argument order (base → conditional → override).
  • Import order.
  • Folder layout (ui/ vs shared/ vs layout/ vs feature).
  • When to create a shared component vs inline.
  • How to add a new token (which tier, what naming pattern).
  • Spacing rule (flex-gap preferred, margin only for prose flow).

Why: without these notes, future-you and anyone else on the project — human or AI — will quietly drift in different directions.

Done when:

  • A new contributor can write a component matching house style without asking questions.