Back to blog

How to build a design system for a startup: a step-by-step recipe

Every startup rebuilds the same button five times before anyone notices. One screen has a 6px radius, the next 8px; one green is #22c55e, another #21c65b because someone eyeballed it. None of it is a bug — and that's exactly why it's dangerous. A design system is how you stop paying that tax without slowing the team down.

This is the recipe I'd follow to build one for a product company. It's framework-agnostic on purpose: the ideas don't belong to React or Angular. At the end I show the same component in both, so you can see how little of a design system is actually about your framework.

Live reference: Simple Design System by Figma, licensed CC BY 4.0.

A design system is not a component library

This is the first misconception to kill. A component library is a bag of buttons and inputs. A design system is the set of decisions — and the rules that keep them consistent — that a component library is just one output of. Four layers, from the ground up:

  1. Foundations → the raw decisions: color, spacing, typography, radii, motion. These are your design tokens.
  2. Components → foundations assembled into reusable pieces with a clear API.
  3. Documentation → how and when to use each piece, with real examples.
  4. Governance → who owns it, how it changes, how teams adopt it.

Skip the bottom and the top and you don't have a system — you have a folder of components that will drift within two sprints. The magic isn't the button; it's that everyone reaches for the same button and nobody argues about the radius.

A design system isn't what you build. It's what stops every team from rebuilding it.

Does your startup even need one?

Maybe not yet. A design system is leverage, and leverage only pays off at scale — more than one product surface, more than a couple of engineers, a design language you expect to keep. If you're three people shipping an MVP, a tokens.css file and a handful of shared components is your design system, and that's the correct size.

The trap is the opposite: building the cathedral before there's a congregation. The same rule from the tokens post applies at every layer — a premature abstraction is over-engineering. Start with foundations (cheap, always worth it), add components as they repeat a third time, and only formalize governance when more than one team depends on you. Grow the system to match the pain, not the ambition.

The recipe

Here are the steps in the order I'd actually do them. Each one is small on its own; the value is in the sequence.

Step 1 · Foundations: design tokens

Everything starts here. A token is a design decision with a name--color-accent, not #22c55e. Organize them in three layers so a single change cascades everywhere: primitives (what values exist), semantic (what they mean), and the optional component layer (a configurable knob). I wrote the full breakdown in Design tokens in 3 layers, so I won't repeat it — the key idea for a system is that tokens are the contract. Every component consumes semantic tokens and nothing else.

/* tokens.css — the single source, framework-agnostic */
:root {
  /* primitive */
  --green-500: #22c55e;
  --neutral-950: #0c0a09;
  --space-3: 0.75rem;
  --radius-md: 0.75rem;

  /* semantic — the contract components consume */
  --color-accent: var(--green-500);
  --color-text: var(--neutral-950);
  --button-radius: var(--radius-md);
}

If you want tokens that also feed native apps or emails, define them in JSON and generate the platform outputs with Style Dictionary. For a web-only startup, plain CSS custom properties are enough — don't add the build step until you have a second platform.

Step 2 · Make the tokens framework-agnostic

This is the step most teams skip, and it's the one that makes the rest cheap. Ship your tokens as CSS custom properties, imported once at the app root. Then React and Angular consume the exact same variables — no duplicated palettes, no per-framework theme objects to keep in sync.

// React entry — main.tsx
import './tokens.css';
// Angular entry — angular.json "styles": ["src/tokens.css"]
// or a single @import in styles.css

The payoff: your Button's color lives in var(--color-accent) regardless of framework. When design changes the accent, you edit one line and both apps update. The tokens are the product; the components are just typed wrappers around them.

Step 3 · Components and a good API

A component is only as good as its API. The question isn't "does it render a button" — it's "can someone use it correctly without reading the source?" A good component API has:

  • Variants, not booleans-per-style. Prefer variant="primary" | "ghost" over isPrimary + isGhost (which lets you set both and get nonsense).
  • A size scale that maps to tokens (sm | md | lg), not arbitrary pixel props.
  • Explicit states: hover, focus, disabled, loading — designed, not accidental.
  • Composition via children/slots, so the button doesn't need a prop for every possible label.
  • Accessibility baked in: real <button> semantics, visible focus ring, aria-* where needed. This isn't a nice-to-have; it's the difference between a component and a liability.

Design the spec once — variants, sizes, states, a11y — and it holds across frameworks. The implementation is a detail. For prior art on API shape, Radix UI (React) and the Angular CDK are the references I'd study.

Step 4 · Theming is a swap, not a rewrite

Dark mode and brand theming look scary until you realize they touch only the semantic layer. Primitives stay put, components never change — you just reassign what the semantic tokens point to.

:root[data-theme='dark'] {
  --color-text: var(--neutral-50);
  --color-bg: var(--neutral-950);
  --color-accent: var(--green-400); /* lighter, to stay legible on dark */
}

A Button that wrote var(--color-accent) re-themes itself with zero new lines of code. That's the whole reason the token contract exists — theming is a variant of the semantic layer, not a fourth layer. (I go deep on this in the tokens post.)

Step 5 · Documentation and discoverability

An undocumented component doesn't exist — people will rebuild it because they didn't know it was there. Storybook is the standard: it renders each component in isolation, shows every variant and state, and doubles as a visual test surface. For each component, document the three things people actually need:

  • When to use it (and when to reach for something else).
  • The props/inputs, with defaults.
  • Do / Don't examples — the fastest way to prevent misuse.

Public systems like Shopify Polaris, GitHub Primer and IBM Carbon are worth studying not for their components but for how they document and govern them.

Step 6 · Governance and contribution

This is what separates a system that lasts from a repo that rots. Governance is unglamorous but decisive:

  • Ownership: one team or person is accountable, even part-time. A system with no owner drifts.
  • Naming conventions: agree how tokens and components are named before you have 200 of them. Nathan Curtis' Naming Tokens in Design Systems is the reference.
  • Versioning: use semver so consumers know when a change is breaking.
  • A contribution path: how does a product team propose a new variant? If the answer is "they can't," they'll fork, and your system fragments.

You don't need all of this on day one. You need it before the second team joins.

Step 7 · Distribution

When more than one app consumes the system, publish it as a versioned package (a private npm package, or a monorepo workspace). One source of truth, semver-tagged, updated on a cadence. Until then — a single app — a src/ui/ folder is perfectly fine. Packaging is a solution to a multi-consumer problem; don't pay for it before you have that problem.

The same component in React and Angular

Here's the point of the whole post. Below is one Button spec — variant, size, disabled, driven by tokens — implemented in both frameworks. Watch how the CSS and tokens are identical; only the component syntax differs.

The shared styles, consumed by both:

/* button.css — shared, framework-agnostic, token-driven */
.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  border: none;
  border-radius: var(--button-radius);
  cursor: pointer;
  font: inherit;
}
.btn:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}
.btn[disabled] {
  opacity: 0.5;
  cursor: not-allowed;
}
.btn--primary {
  background: var(--color-accent);
  color: var(--color-bg);
}
.btn--ghost {
  background: transparent;
  color: var(--color-text);
}
.btn--sm {
  padding: var(--space-2) var(--space-3);
}
.btn--md {
  padding: var(--space-3) var(--space-4);
}

React — a typed functional component:

// Button.tsx
import './button.css';

type Variant = 'primary' | 'ghost';
type Size = 'sm' | 'md';

interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: Variant;
  size?: Size;
}

export function Button({ variant = 'primary', size = 'md', className, ...rest }: ButtonProps) {
  const classes = ['btn', `btn--${variant}`, `btn--${size}`, className].filter(Boolean).join(' ');
  // Native <button> keeps semantics, focus and disabled for free
  return <button className={classes} {...rest} />;
}

Angular — a standalone component with signal inputs:

// button.ts
import { Component, input } from '@angular/core';

@Component({
  selector: 'button[appButton]',
  standalone: true,
  template: `<ng-content />`,
  styleUrl: './button.css',
  host: {
    // Same classes, driven by the same token-based CSS
    class: 'btn',
    '[class.btn--primary]': "variant() === 'primary'",
    '[class.btn--ghost]': "variant() === 'ghost'",
    '[class.btn--sm]': "size() === 'sm'",
    '[class.btn--md]': "size() === 'md'",
  },
})
export class ButtonComponent {
  // Same spec as React: variant + size, primary/md defaults
  readonly variant = input<'primary' | 'ghost'>('primary');
  readonly size = input<'sm' | 'md'>('md');
}
<!-- usage -->
<button appButton variant="ghost" size="sm">Cancel</button>

Two syntaxes, one design. The variants, the sizes, the focus ring, the disabled state, the accent color — all defined once in tokens and CSS. If you internalize one thing from this post, let it be this: a design system is mostly framework-agnostic, and the framework layer is the thin part.

Common mistakes

The failure modes are predictable. Watch for these:

  • The component graveyard. Shipping 60 components nobody uses. Build the third repetition, not the hypothetical one.
  • Raw values in components. A #22c55e hardcoded in a button breaks theming silently. Consume semantic tokens, always.
  • Premature component tokens. --button-x, --input-y "just in case." Add a knob when a real override needs it, not before.
  • Documenting last. If docs come after launch, adoption stalls and people rebuild. Document as you build.
  • No governance. No owner, no naming rules, no versioning — and in six months it's a museum.

The checklist

The recipe in one scannable list — this is the part to keep open at work:

  1. Foundations — tokens in 3 layers, semantic layer as the contract.
  2. Agnostic — ship tokens as CSS custom properties, imported once.
  3. Components — variants over booleans, a size scale, real states, a11y baked in.
  4. Theming — a semantic swap, never a rewrite.
  5. Docs — Storybook + when-to-use + do/don't, written as you build.
  6. Governance — an owner, naming rules, semver, a contribution path.
  7. Distribution — a versioned package once there's a second consumer.

Right-size every step to your actual pain. A startup's best design system is the smallest one that keeps the team consistent — and grows only when it hurts not to.

Sources — go deeper

Everything above is a map; these are the territories. Each link is where I'd send someone to learn how it's actually implemented:

A design system isn't the button. It's the agreement that there's only one — and the discipline to keep it that way.