MeshWorld MeshWorld.
Design Systems CSS Frontend Web Dev Developer Tools 7 min read

Building a Design System from Scratch: Tokens, Components, and Documentation

Scarlett
By Scarlett

“We have Tailwind, so we have a design system” is one of the most common misconceptions I hear from frontend teams. Tailwind is a utility library. A design system is a shared language — values, decisions, and constraints that make your UI consistent across teams and time. This guide covers what that actually involves.

:::note[TL;DR]

  • A design system = design tokens + component library + documentation. Any two of three doesn’t count.
  • Design tokens are named values for color, spacing, typography, and motion — the single source of truth
  • Component APIs should be designed before they’re built: what are the props, variants, and composition patterns?
  • Documentation that lives in the codebase (MDX, Storybook) stays current; wikis and Figma descriptions don’t
  • Version your design system like a package: semver, changelog, and a migration guide for breaking changes :::

Why isn’t Tailwind a design system?

Tailwind gives you a vocabulary (bg-blue-500, p-4, text-sm) but not a decision layer. A design system answers “which blue?” and “which spacing scale?” in a way that’s consistent across your entire product. Without that layer, you end up with ten different blues across ten components because different engineers picked different Tailwind values.

A design system built on Tailwind is absolutely valid — it just requires you to add the decision layer on top: design tokens that map your brand values to Tailwind’s scale, and components that encode which tokens apply where.


What are design tokens?

Design tokens are named values for visual properties. Instead of #1a56db scattered through your codebase, you have color.brand.primary that resolves to #1a56db. Change the hex value once, and it changes everywhere.

Token categories:

  • Color — brand palette, semantic colors (success, warning, error), text, backgrounds, borders
  • Spacing — a consistent scale (4px, 8px, 12px, 16px, 24px, 32px, 48px, 64px)
  • Typography — font families, sizes, weights, line heights
  • Border radius — none, sm, md, lg, full
  • Shadow — elevation levels
  • Motion — duration and easing for animations

In practice for a React + Tailwind setup, tokens live in tailwind.config.ts:

// tailwind.config.ts
export default {
  theme: {
    extend: {
      colors: {
        brand: {
          primary: '#1a56db',
          secondary: '#e3a008',
          danger: '#e02424',
        },
        surface: {
          DEFAULT: '#ffffff',
          muted: '#f9fafb',
          emphasis: '#f3f4f6',
        },
        text: {
          primary: '#111827',
          secondary: '#6b7280',
          disabled: '#9ca3af',
        },
      },
      spacing: {
        // Extend with semantic spacing names
        'component-padding': '1rem',
        'section-gap': '3rem',
      },
    },
  },
};

For token management across design and code, tools like Style Dictionary let you define tokens in JSON and export them to CSS custom properties, Tailwind config, and design tool formats simultaneously — one source, multiple outputs.


How do you design component APIs?

Before writing a single line of component code, write the usage. What does the API look like from the outside?

Start with the variants a component needs:

// What you want to be able to write:
<Button variant="primary" size="md" loading={false}>Submit</Button>
<Button variant="ghost" size="sm" icon={<ArrowLeft />}>Back</Button>
<Button variant="danger" disabled>Delete account</Button>

Then define the props type:

type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
  icon?: React.ReactNode;
}

Three patterns to choose from for component composition:

Props-based variants (above) — simple, predictable, easy to document. Right for most components.

Compound components — for complex components with meaningful substructure:

<Select>
  <Select.Trigger>Choose an option</Select.Trigger>
  <Select.Content>
    <Select.Item value="a">Option A</Select.Item>
    <Select.Item value="b">Option B</Select.Item>
  </Select.Content>
</Select>

Slot-based composition — for layouts and wrappers where the structure matters but content varies:

<Card>
  <Card.Header>
    <h2>Title</h2>
  </Card.Header>
  <Card.Body>Content here</Card.Body>
  <Card.Footer>
    <Button>Action</Button>
  </Card.Footer>
</Card>

Don’t mix all three in the same component family — pick a pattern and be consistent.


What documentation actually gets used?

This is where most design systems quietly fail. The components exist, but nobody knows how to use them correctly, so engineers write their own versions or misuse the existing ones.

Documentation that works:

  • MDX stories in the same repo — Storybook stories live next to components. When the component changes, the story breaks immediately if it’s not updated. Engineers see documentation as part of the component, not separate.
  • Usage examples with real-world context — not just <Button>Click me</Button>, but <Button variant="danger" onClick={handleDelete}>Delete this record</Button> in the context of a confirmation dialog.
  • Do/don’t examples — showing the wrong way prevents the common mistakes.
  • Copy-paste-ready code — nobody reads prose documentation; they scan for the code snippet they need.

Documentation that doesn’t work:

  • A Confluence page that was accurate when someone wrote it in 2024
  • Figma component descriptions that designers update and engineers never see
  • A separate documentation site that drifts from the actual component implementation

How do you version a design system?

Treat it like any other package: semantic versioning with a changelog.

  • Patch (1.0.x) — bug fixes, no API changes. Safe to update.
  • Minor (1.x.0) — new components or props, fully backward compatible. Safe to update.
  • Major (x.0.0) — breaking changes: renamed props, removed components, changed token values that affect visual output. Requires a migration guide.

Breaking changes are expensive for teams consuming your design system. Before shipping a breaking change, ask: can this be done additively instead? Add a new prop alongside the old one, deprecate the old one, and remove it in the next major version. That’s more work but spreads the migration cost across releases.

A minimal CHANGELOG.md entry for a breaking change:

## 3.0.0 (2026-04-15)

### Breaking changes
- `Button` prop `type` renamed to `variant` (was conflicting with native HTML `type` attribute)
  - Migration: replace `type="primary"` with `variant="primary"` throughout

### New features
- `Tooltip` component added

Summary

  • Design tokens are the foundation — color, spacing, typography defined once and consumed everywhere
  • Component APIs should be designed (the usage written out) before they’re implemented
  • Compound components for complex, slot-based for layouts, props variants for simple components
  • Documentation in the same repo as the code is the only kind that stays accurate
  • Version with semver, write changelogs, provide migration guides for breaking changes

FAQ

Should we build our own component library or use Radix/Shadcn?

If your design requirements are standard, start with Radix UI (unstyled, accessible primitives) and layer your tokens on top. This gives you the hard parts for free — keyboard navigation, ARIA attributes, focus management — and lets you control every visual detail. Shadcn is Radix + sensible defaults in a copy-paste model, which works well when you want a starting point without a dependency on an external package. Build fully custom only when your design is genuinely unusual or your team has bandwidth to maintain everything.

How big should the token set be?

Small enough that engineers can remember the key ones. A color palette with 8–12 semantic color names is manageable. Fifty shades of gray with numeric suffixes is not a design system — it’s a design library. The goal is that any engineer can correctly choose a color without looking anything up. That happens when there are one or two right answers per context, not fifty.

How do you keep design and engineering tokens in sync?

Single source of truth: the token file in the codebase exports to both engineering (Tailwind config, CSS custom properties) and design (Figma via a plugin like Token Studio or Theo). Figma values are read-only, generated from the token file. Design proposes changes to the token file via PR; engineering reviews and merges. This is more process overhead than letting designers edit Figma freely, but it prevents the two systems from drifting.