Back to Blog

How to Build a Design System From Scratch (Step-by-Step)

How to Build a Design System From Scratch (Step-by-Step)

Most design systems fail because they start in the wrong place. Someone creates a Figma library with 47 button variants, writes a manifesto about atomic design, and six months later the engineering team is still using inline styles because the system does not match reality.

We have built design systems for multiple products — including MindHyv, an all-in-one business platform with social feeds, booking flows, invoicing, and a digital storefront all living under one roof. That project taught us more about practical design systems than any blog post or conference talk ever could.

Here is how we actually build design systems, step by step, without the ceremony that kills most efforts before they ship.

Start With Tokens, Not Components

Design tokens are the foundation. They are the named values — colors, spacing, typography, shadows, border radii — that every component will eventually reference. Getting tokens right means every future component inherits consistency for free.

Here is what a minimal token setup looks like in CSS custom properties:

:root {
  /* Colors */
  --color-primary: #2563eb;
  --color-primary-hover: #1d4ed8;
  --color-surface: #ffffff;
  --color-surface-raised: #f8fafc;
  --color-border: #e2e8f0;
  --color-text: #0f172a;
  --color-text-muted: #64748b;

  /* Spacing (4px base unit) */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-12: 3rem;
  --space-16: 4rem;

  /* Typography */
  --font-sans: 'Inter', system-ui, sans-serif;
  --font-mono: 'JetBrains Mono', monospace;
  --text-sm: 0.875rem;
  --text-base: 1rem;
  --text-lg: 1.125rem;
  --text-xl: 1.25rem;
  --text-2xl: 1.5rem;
  --text-3xl: 1.875rem;

  /* Shadows */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.07);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);

  /* Radii */
  --radius-sm: 0.25rem;
  --radius-md: 0.5rem;
  --radius-lg: 0.75rem;
  --radius-full: 9999px;
}

If you are using Tailwind CSS, you can define these in your tailwind.config.ts and get utility classes that reference them automatically:

import type { Config } from "tailwindcss";

export default {
  theme: {
    extend: {
      colors: {
        primary: {
          DEFAULT: "#2563eb",
          hover: "#1d4ed8",
        },
        surface: {
          DEFAULT: "#ffffff",
          raised: "#f8fafc",
        },
      },
      spacing: {
        18: "4.5rem",
        88: "22rem",
      },
      borderRadius: {
        DEFAULT: "0.5rem",
      },
    },
  },
} satisfies Config;

The key rule: do not invent tokens speculatively. Pull them from your existing UI. Open every screen of your product, note every color, every spacing value, every font size actually in use. Then consolidate. You will find that your “23 shades of gray” can collapse to 4 or 5 meaningful semantic values.

When we built MindHyv’s design system, we started by auditing the existing screens — the social feed, the booking calendar, the invoice builder, the storefront. We found 11 different grays in use. We collapsed them to 5: text, text-muted, border, surface, and surface-raised. Every component got simpler overnight.

A UI kit library with organized design components and reusable interface elements

Extract Components From Real UI

This is where most design systems go wrong. Teams build components in isolation — a Button component with every conceivable prop, a Card component that handles 14 layout variations — and then wonder why developers avoid using them.

The better approach: build the product first, then extract.

Look at your actual codebase. Find patterns that repeat across three or more screens. Those are your components. Not theoretical composable primitives — real, recurring UI patterns.

For MindHyv, our first extracted components were:

  1. PageHeader — title, optional description, optional action buttons. Used on every single page.
  2. EmptyState — icon, heading, description, CTA. Used in feeds, lists, search results.
  3. StatusBadge — colored pill with label. Used in bookings, invoices, orders.
  4. FormField — label, input slot, error message, helper text. Used in every form.

Here is what a practical component extraction looks like in Svelte:

<!-- StatusBadge.svelte -->
<script lang="ts">
  type Variant = "success" | "warning" | "error" | "info" | "neutral";

  export let variant: Variant = "neutral";
  export let label: string;

  const styles: Record<Variant, string> = {
    success: "bg-emerald-50 text-emerald-700 border-emerald-200",
    warning: "bg-amber-50 text-amber-700 border-amber-200",
    error: "bg-red-50 text-red-700 border-red-200",
    info: "bg-blue-50 text-blue-700 border-blue-200",
    neutral: "bg-slate-50 text-slate-700 border-slate-200",
  };
</script>

<span
  class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs
         font-medium border {styles[variant]}"
>
  {label}
</span>

Notice what this component does not have: it does not accept arbitrary classNames, it does not have a size prop with 5 options, it does not render icons. It does exactly what our product needs. When we need more, we add more — driven by actual requirements, not speculation.

The Three-Instance Rule

We follow a simple rule: do not extract a component until you have seen the pattern in at least three places. Two instances might be coincidence. Three is a pattern.

This prevents the most common design system disease — premature abstraction. A prematurely abstracted component accumulates props and conditional logic to handle every edge case. Eventually it becomes harder to use than writing the UI from scratch.

When you find a third instance of a pattern:

  1. Look at all three usages side by side
  2. Identify what is truly shared (the skeleton)
  3. Identify what varies (the slots, the props)
  4. Extract the minimal component that handles all three cases
  5. Replace all three usages with the component

If you cannot cleanly handle all three cases without the component becoming convoluted, the pattern might not be as consistent as you think. That is fine. Leave it alone.

Document With Real Examples, Not Specifications

Design system documentation that reads like an API reference is documentation that nobody reads. The best docs show the component in context — in a layout, with realistic data, doing what it actually does in the product.

For every component, we document three things:

  1. A live example with realistic content (not “Lorem ipsum” or “Button text”)
  2. The prop interface — what you can pass and what it does
  3. Usage guidelines — when to use this component vs. alternatives

Here is how we structure a documentation page:

## StatusBadge

Displays a colored label for entity states — booking status,
invoice status, order status.

### Examples

- Booking confirmed: `<StatusBadge variant="success" label="Confirmed" />`
- Payment pending: `<StatusBadge variant="warning" label="Pending" />`
- Invoice overdue: `<StatusBadge variant="error" label="Overdue" />`

### Props

| Prop    | Type    | Default   | Description          |
| ------- | ------- | --------- | -------------------- |
| variant | Variant | "neutral" | Visual style         |
| label   | string  | required  | Text displayed       |

### When to use

Use StatusBadge for entity lifecycle states. Do not use for
counts (use a plain number), categories (use a tag/chip), or
toggleable states (use a toggle).

The “when to use” section is the most valuable part. It prevents the component from becoming a catch-all for every small colored text element in the product.

For a deeper dive into scaling this documentation approach across multiple products and teams, see our post on design systems at scale.

A color palette spread showing harmonious design swatches for a cohesive visual system

Build a Token Pipeline

Once your tokens are defined, you need a way to distribute them. In a single-product setup, CSS custom properties or a Tailwind config might be enough. But if you are building multiple products or maintaining a design system across platforms, you need a pipeline.

We use a simple JSON-based approach:

{
  "color": {
    "primary": { "value": "#2563eb", "type": "color" },
    "primary-hover": { "value": "#1d4ed8", "type": "color" },
    "surface": { "value": "#ffffff", "type": "color" },
    "text": { "value": "#0f172a", "type": "color" }
  },
  "spacing": {
    "1": { "value": "0.25rem", "type": "dimension" },
    "2": { "value": "0.5rem", "type": "dimension" },
    "4": { "value": "1rem", "type": "dimension" },
    "8": { "value": "2rem", "type": "dimension" }
  }
}

A build script transforms this into platform-specific formats:

import { readFileSync, writeFileSync } from "fs";

interface Token {
  value: string;
  type: string;
}

type TokenGroup = Record<string, Token>;
type TokenFile = Record<string, TokenGroup>;

function generateCSS(tokens: TokenFile): string {
  let css = ":root {\n";
  for (const [group, values] of Object.entries(tokens)) {
    for (const [name, token] of Object.entries(values)) {
      css += `  --${group}-${name}: ${token.value};\n`;
    }
  }
  css += "}\n";
  return css;
}

function generateTailwindConfig(tokens: TokenFile): string {
  const theme: Record<string, Record<string, string>> = {};
  for (const [group, values] of Object.entries(tokens)) {
    theme[group] = {};
    for (const [name, token] of Object.entries(values)) {
      theme[group][name] = token.value;
    }
  }
  return `export default ${JSON.stringify({ theme: { extend: theme } }, null, 2)};`;
}

const tokens: TokenFile = JSON.parse(
  readFileSync("tokens.json", "utf-8")
);

writeFileSync("tokens.css", generateCSS(tokens));
writeFileSync("tailwind.tokens.ts", generateTailwindConfig(tokens));

This gives you a single source of truth that feeds both your CSS and your Tailwind config. When a color changes, you update it in one place.

Version Deliberately

If your design system lives inside a monorepo alongside your product, you might not need formal versioning at all. Changes to the system are changes to the product — they ship together, they break together, they get fixed together.

If your design system is a shared package consumed by multiple products, version it. Use semantic versioning. Be explicit about what constitutes a breaking change:

  • Removing a component or prop: breaking
  • Changing a token value: minor (visual change, no API change)
  • Adding a new component or prop: minor
  • Fixing a visual bug: patch

Keep a changelog. Not an automated one from commit messages — a human-written changelog that explains what changed and why it matters to consumers.

A clean component library interface displayed on a monitor showing structured UI elements

Dark Mode From Day One

If there is any chance your product will need dark mode, build the token layer for it from the start. Retrofitting dark mode onto a system built with hardcoded colors is miserable.

The approach is straightforward — semantic tokens that reference different values per theme:

:root {
  --color-surface: #ffffff;
  --color-surface-raised: #f8fafc;
  --color-text: #0f172a;
  --color-text-muted: #64748b;
  --color-border: #e2e8f0;
}

[data-theme="dark"] {
  --color-surface: #0f172a;
  --color-surface-raised: #1e293b;
  --color-text: #f1f5f9;
  --color-text-muted: #94a3b8;
  --color-border: #334155;
}

Every component that uses var(--color-surface) automatically adapts. No component-level dark mode logic needed.

Avoid These Common Mistakes

Do not start with Figma. Start with code. Figma is a communication tool, not a source of truth. Your token JSON file is the source of truth. Figma can reflect it, but it should not drive it.

Do not build a component library before you have a product. A design system without a product is a solution looking for a problem. Build the product. Extract the system.

Do not copy Material Design or any other public system. Their constraints are not your constraints. Google has 4,000 products. You probably have 1-3. Your system should be 10x simpler.

Do not optimize for theoretical flexibility. Every prop you add is a decision your developers have to make. Fewer decisions means faster development means more consistent UI.

The Minimum Viable Design System

If you are starting from zero today, here is your checklist:

  1. Define 5-8 colors as semantic tokens (primary, surface, text, border, error, success)
  2. Pick a spacing scale (we use 4px base: 4, 8, 12, 16, 24, 32, 48, 64)
  3. Pick 2 fonts max (one sans, one mono) with 4-5 size steps
  4. Extract your first 3-5 components from actual product UI
  5. Write one-paragraph usage guidelines for each component
  6. Put it all in a shared directory or package

That is it. That is a design system. Everything else — Storybook, Chromatic visual regression testing, Figma sync plugins, design token APIs — is optimization. Do not optimize before you have something to optimize.

We followed exactly this process with MindHyv, and it scaled from a handful of tokens and 5 components to a full system supporting social feeds, calendar views, invoice PDFs, and a public storefront. The foundation held because we built it from real needs, not theoretical purity.

If you are building something similar and want help setting up a design system that actually gets used, reach out at hello@threshline.com.