I've seen it happen three times now. A company decides they need a design system. Someone creates a Figma file with 47 colour swatches and names it "Design System v1." Six months later, nobody uses it, the buttons still look different on every page, and the developers are back to copying CSS from Stack Overflow. The design system is declared dead before it ever lived.
Here's the thing nobody tells you upfront: a design system is not a Figma file. It's not a component library either. It's a living product — with users (your own teams), documentation, versioning, and maintenance. And like any product, it fails when you don't treat it like one.
This guide walks you through building a design system that actually gets adopted. Not a theoretical framework — a practical, opinionated guide based on what works in production. Let's get into it.
What a Design System Actually Is
Let me clear up the confusion first, because people mix these terms up constantly:
Most people skip straight to building components. That's like building a house by starting with the furniture. You need foundations first.
Step 1: Define Your Design Principles
Before you touch a single pixel or write a line of CSS, answer these questions with your team. Write the answers down. These become your design principles — the north star for every decision:
- Who are your users? Internal tools for developers? Consumer-facing app for non-tech people? Both?
- What's your brand personality? Playful and colourful (like Notion)? Professional and restrained (like Linear)? Bold and opinionated (like Vercel)?
- What are your constraints? Must support dark mode? Must work on mobile? Must be accessible (WCAG AA)?
- What's your tech stack? React? Angular? Vue? Web Components? All of them?
Here's an example of good design principles (inspired by real systems):
Step 2: Design Tokens — Your Single Source of Truth
Design tokens are the atomic values that define your visual language. Think of them as CSS variables on steroids. They're the reason you can change your brand colour in one place and have it update across 200 components instantly.
/* tokens.css — Your design token foundation */
:root {
/* ── Colours ─────────────────────────────── */
/* Primitive colours (raw palette — don't use directly in components) */
--color-blue-50: #eff6ff;
--color-blue-100: #dbeafe;
--color-blue-500: #3b82f6;
--color-blue-600: #2563eb;
--color-blue-700: #1d4ed8;
--color-blue-900: #1e3a5f;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-500: #6b7280;
--color-gray-700: #374151;
--color-gray-900: #111827;
--color-red-500: #ef4444;
--color-green-500: #22c55e;
--color-yellow-500: #eab308;
/* Semantic colours (USE THESE in components) */
--color-primary: var(--color-blue-600);
--color-primary-hover: var(--color-blue-700);
--color-primary-light: var(--color-blue-50);
--color-background: #ffffff;
--color-surface: var(--color-gray-50);
--color-border: var(--color-gray-200);
--color-text: var(--color-gray-900);
--color-text-secondary: var(--color-gray-500);
--color-text-inverse: #ffffff;
--color-success: var(--color-green-500);
--color-error: var(--color-red-500);
--color-warning: var(--color-yellow-500);
/* ── Typography ──────────────────────────── */
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
--text-xs: 0.75rem; /* 12px */
--text-sm: 0.875rem; /* 14px */
--text-base: 1rem; /* 16px */
--text-lg: 1.125rem; /* 18px */
--text-xl: 1.25rem; /* 20px */
--text-2xl: 1.5rem; /* 24px */
--text-3xl: 1.875rem; /* 30px */
--text-4xl: 2.25rem; /* 36px */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
--line-height-tight: 1.25;
--line-height-normal: 1.5;
--line-height-relaxed: 1.75;
/* ── Spacing (8px base grid) ─────────────── */
--space-0: 0;
--space-1: 0.25rem; /* 4px */
--space-2: 0.5rem; /* 8px */
--space-3: 0.75rem; /* 12px */
--space-4: 1rem; /* 16px */
--space-5: 1.25rem; /* 20px */
--space-6: 1.5rem; /* 24px */
--space-8: 2rem; /* 32px */
--space-10: 2.5rem; /* 40px */
--space-12: 3rem; /* 48px */
--space-16: 4rem; /* 64px */
--space-20: 5rem; /* 80px */
/* ── Border Radius ───────────────────────── */
--radius-none: 0;
--radius-sm: 0.25rem; /* 4px */
--radius-md: 0.375rem; /* 6px */
--radius-lg: 0.5rem; /* 8px */
--radius-xl: 0.75rem; /* 12px */
--radius-2xl: 1rem; /* 16px */
--radius-full: 9999px; /* Pill shape */
/* ── Shadows ─────────────────────────────── */
--shadow-sm: 0 1px 2px rgba(0,0,0,0.05);
--shadow-md: 0 4px 6px -1px rgba(0,0,0,0.1);
--shadow-lg: 0 10px 15px -3px rgba(0,0,0,0.1);
--shadow-xl: 0 20px 25px -5px rgba(0,0,0,0.1);
/* ── Breakpoints (use in media queries) ──── */
/* --bp-sm: 640px; --bp-md: 768px; --bp-lg: 1024px; --bp-xl: 1280px; */
/* ── Transitions ─────────────────────────── */
--transition-fast: 150ms ease;
--transition-normal: 200ms ease;
--transition-slow: 300ms ease;
/* ── Z-Index Scale ───────────────────────── */
--z-dropdown: 100;
--z-sticky: 200;
--z-modal-backdrop: 300;
--z-modal: 400;
--z-toast: 500;
--z-tooltip: 600;
}
/* ── Dark Mode ─────────────────────────────── */
.dark {
--color-primary: var(--color-blue-500);
--color-primary-hover: var(--color-blue-600);
--color-primary-light: rgba(59, 130, 246, 0.1);
--color-background: #0a0a0a;
--color-surface: #1a1a1a;
--color-border: #2a2a2a;
--color-text: #fafafa;
--color-text-secondary: #a1a1aa;
}
The golden rule: Components should NEVER use raw colour values like #3b82f6. They should ALWAYS reference semantic tokens like var(--color-primary). This is what makes dark mode, theming, and brand updates possible without touching component code.
Step 3: Component Architecture
Right, now we're into the fun part. Let me show you how to build components that are genuinely reusable — not the "reusable in theory but customised differently on every page" kind.
A well-architected component follows what I call the three-layer pattern:
Here's a real example — a production-grade Button component:
/* Button.tsx — A proper design system button */
interface ButtonProps {
/** Visual style variant */
variant?: 'primary' | 'secondary' | 'ghost' | 'destructive';
/** Size preset */
size?: 'sm' | 'md' | 'lg';
/** Show loading spinner and disable interactions */
loading?: boolean;
/** Full width of parent container */
fullWidth?: boolean;
/** Icon to show before the label */
icon?: React.ReactNode;
/** Render as a different HTML element (e.g., 'a' for links) */
as?: React.ElementType;
/** All native button attributes are forwarded */
[key: string]: any;
}
export function Button({
variant = 'primary',
size = 'md',
loading = false,
fullWidth = false,
icon,
as: Component = 'button',
children,
disabled,
className,
...props
}: ButtonProps) {
return (
<Component
className={cn(
// Base styles (shared across all variants)
'inline-flex items-center justify-center gap-2',
'font-semibold rounded-lg transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:opacity-50 disabled:pointer-events-none',
// Size variants
size === 'sm' && 'h-8 px-3 text-xs',
size === 'md' && 'h-10 px-4 text-sm',
size === 'lg' && 'h-12 px-6 text-base',
// Visual variants
variant === 'primary' && 'bg-[var(--color-primary)] text-white hover:bg-[var(--color-primary-hover)] shadow-sm',
variant === 'secondary' && 'bg-[var(--color-surface)] text-[var(--color-text)] border border-[var(--color-border)] hover:bg-[var(--color-border)]',
variant === 'ghost' && 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface)] hover:text-[var(--color-text)]',
variant === 'destructive' && 'bg-[var(--color-error)] text-white hover:opacity-90',
// Modifiers
fullWidth && 'w-full',
className,
)}
disabled={disabled || loading}
{...props}
>
{loading ? <Spinner size={size} /> : icon}
{children}
</Component>
);
}
/* Usage examples:
<Button>Save Changes</Button>
<Button variant="secondary" size="sm">Cancel</Button>
<Button variant="destructive" loading>Deleting...</Button>
<Button variant="ghost" icon={<PlusIcon />}>Add Item</Button>
<Button as="a" href="/login">Sign In</Button>
*/
Step 4: The Component Checklist
Before any component ships to the design system, it must pass this checklist. I'm serious — tape this to your monitor:
Step 5: Spacing and Layout System
Inconsistent spacing is the number one reason UIs look "off" even when the colours and fonts are right. Humans are weirdly good at detecting 12px of padding on one side and 14px on the other. Your brain screams "something is wrong" even if you can't articulate what.
The fix: use an 8px base grid. All spacing values are multiples of 8px (with 4px allowed for tight spaces). This creates a visual rhythm that feels intentional and harmonious.
/* Spacing scale (8px base grid) */
--space-1: 4px; /* Tight: icon-to-label gap */
--space-2: 8px; /* Default: between related items */
--space-3: 12px; /* Medium: section padding */
--space-4: 16px; /* Regular: card padding, input padding */
--space-6: 24px; /* Generous: between sections */
--space-8: 32px; /* Spacious: section margins */
--space-12: 48px; /* Page sections */
--space-16: 64px; /* Major sections */
/* Usage in components: */
.card {
padding: var(--space-4); /* 16px all around */
gap: var(--space-3); /* 12px between card children */
border-radius: var(--radius-xl); /* 12px corners */
box-shadow: var(--shadow-sm);
}
.card-header {
margin-bottom: var(--space-3); /* 12px below header */
}
.card-actions {
margin-top: var(--space-4); /* 16px above actions */
gap: var(--space-2); /* 8px between buttons */
}
Step 6: Typography System
Don't overthink this. You need exactly 6-8 text styles. Not 15. Not 20. Six to eight. Here's a battle-tested scale:
/* Typography presets — the only text styles you need */
.text-display {
font-size: var(--text-4xl); /* 36px — Hero headings only */
font-weight: var(--font-weight-bold);
line-height: var(--line-height-tight);
letter-spacing: -0.02em;
}
.text-heading-1 {
font-size: var(--text-2xl); /* 24px — Page titles */
font-weight: var(--font-weight-bold);
line-height: var(--line-height-tight);
}
.text-heading-2 {
font-size: var(--text-xl); /* 20px — Section headers */
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
}
.text-heading-3 {
font-size: var(--text-lg); /* 18px — Card titles */
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-normal);
}
.text-body {
font-size: var(--text-base); /* 16px — Default body */
font-weight: var(--font-weight-normal);
line-height: var(--line-height-relaxed);
}
.text-body-sm {
font-size: var(--text-sm); /* 14px — Secondary text */
font-weight: var(--font-weight-normal);
line-height: var(--line-height-normal);
}
.text-caption {
font-size: var(--text-xs); /* 12px — Labels, metadata */
font-weight: var(--font-weight-medium);
line-height: var(--line-height-normal);
letter-spacing: 0.02em;
}
.text-code {
font-family: var(--font-mono);
font-size: var(--text-sm);
line-height: var(--line-height-normal);
}
Step 7: Documentation — The Make-or-Break
I cannot stress this enough: the documentation IS the design system. Not the Figma file. Not the code. The docs. Because if a developer can't figure out how to use your Button component in under 30 seconds, they'll write their own. And now you have two buttons.
Every component page in your docs should have exactly these sections:
Step 8: Versioning and Distribution
Your design system is a product. Products have versions. Here's how to handle this without making everyone's life miserable:
# Package structure for a design system
my-design-system/
packages/
tokens/ # Design tokens (CSS, JSON, JS exports)
package.json # @myds/tokens
src/
colors.ts
spacing.ts
typography.ts
core/ # Core components (Button, Input, Modal, etc.)
package.json # @myds/core
src/
Button/
Button.tsx
Button.test.tsx
Button.stories.tsx
index.ts
Input/
Modal/
icons/ # Icon library
package.json # @myds/icons
docs/ # Documentation site (Storybook or custom)
.changeset/ # Changesets for versioning
# Install in a consuming project:
npm install @myds/core @myds/tokens
# Use:
import { Button, Input, Modal } from '@myds/core';
import '@myds/tokens/css'; # Import design tokens
# Versioning with Changesets (recommended)
# https://github.com/changesets/changesets
npx changeset init # One-time setup
npx changeset # Create a changeset (describe your change)
npx changeset version # Bump versions based on changesets
npx changeset publish # Publish to npm
# Semantic versioning rules:
# PATCH (1.0.1): Bug fix, no API change
# MINOR (1.1.0): New component, new prop, backwards compatible
# MAJOR (2.0.0): Breaking change (renamed prop, removed component)
# Golden rule: NEVER ship a breaking change without a migration guide.
# If you rename a prop, provide a codemod or deprecation warning first.
Step 9: The Mistakes That Kill Design Systems
I've watched enough design systems fail to know the patterns. Here are the big ones — learn from other people's pain:
Step 10: Measuring Success
How do you know if your design system is working? Not by counting components — by measuring adoption:
# Metrics that actually matter:
# 1. Adoption rate
# How many pages use design system components vs custom ones?
# Target: > 80% of UI elements from the system
# 2. Time to first component
# How long from "npm install" to rendering a Button?
# Target: < 5 minutes (including reading docs)
# 3. Contribution rate
# Are teams outside the DS team contributing components?
# A healthy system gets PRs from consuming teams
# 4. Bug reports
# Track bugs filed against DS components vs custom components
# DS components should have fewer bugs (they're tested once, used everywhere)
# 5. Developer satisfaction (survey quarterly)
# "On a scale of 1-5, how easy is it to build a new page?"
# Before DS: typically 2-3
# After DS: should be 4-5
The Starter Kit: What to Build First
Don't try to build everything. Here's the order that gives maximum value with minimum effort:
That's it — tokens + 4 components. Ship those, get teams using them, collect feedback, then build the next batch based on what teams actually need (not what you think they need).
Real Design Systems to Study
Don't build yours in a vacuum. Study these — they're all open source:
- Radix UI (radix-ui.com) — Headless primitives, best accessibility. React.
- shadcn/ui (ui.shadcn.com) — Copy-paste components, Tailwind CSS. The "anti-library" approach.
- Spartan UI (spartan.ng) — shadcn/ui philosophy for Angular. Headless brain + styled helm.
- Chakra UI (chakra-ui.com) — Accessible, composable, theme-able. React.
- Material Design (m3.material.io) — Google's system. Very thorough documentation and guidelines.
- Primer (primer.style) — GitHub's design system. Production-proven at massive scale.
- Carbon (carbondesignsystem.com) — IBM's system. Enterprise-grade, highly structured.
Final Thought
Here's the honest truth: building a design system is not hard. Getting people to use it is. The technical part — tokens, components, docs — is maybe 30% of the work. The other 70% is people work: building trust with product teams, listening to feedback, making adoption effortless, being responsive to bug reports, and resisting the urge to make it "perfect" before anyone uses it.
Ship small. Ship often. Listen more than you prescribe. A design system that 80% of your teams use happily is worth infinitely more than a "complete" system that sits in a repo with 3 stars and zero consumers.
Now go build something. Start with the tokens. I'll be here if you get stuck.