MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr 🔥 tagline

Hey there 👋 I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 — present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 — Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms — earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Building a Design System from Scratch

Build a complete design system: tokens, components, accessibility, documentation, and team governance.

Design SystemsUIFrontendAccessibility

By MinhVo

Introduction

A design system is more than a component library. It's a shared language between designers and developers—a set of reusable components, design tokens, patterns, and guidelines that ensure consistency across products and teams. Companies like Shopify (Polaris), Salesforce (Lightning), and Atlassian (Atlassian Design System) have demonstrated that a well-built design system accelerates development, improves accessibility, and creates a cohesive user experience.

Building a design system from scratch is a significant engineering investment that requires careful planning. You need to make foundational decisions about technology, theming, accessibility, documentation, and governance. This guide walks through every major decision, from choosing a CSS methodology to establishing a contribution model. Whether you're building for a startup's first product or an enterprise's suite of applications, the principles and patterns here apply.

Design system overview

Understanding Design Systems: Core Concepts

The Three Layers

Every design system has three layers:

  1. Design Tokens: The atomic values—colors, typography, spacing, shadows, and border radii—that define the visual language. Tokens are platform-agnostic and can be consumed by CSS, JavaScript, iOS, Android, and design tools.

  2. Components: Reusable UI building blocks—buttons, inputs, modals, tables, navigation—that implement the design tokens. Components are the primary developer-facing API of the system.

  3. Patterns: Compositions of components that solve common UX problems—forms, data tables with filtering, authentication flows, empty states. Patterns provide guidance on how to combine components effectively.

Design Tokens: The Foundation

Design tokens are named values that represent design decisions. Instead of hardcoding #0066FF in CSS, you define color-primary-500 and reference it everywhere. Tokens create a single source of truth that designers and developers share.

Tokens are organized into tiers:

  • Global tokens: Raw values (e.g., blue-500: #0066FF, spacing-4: 16px)
  • Alias tokens: Semantic references (e.g., color-action-primary: {blue-500}, spacing-component-gap: {spacing-4})
  • Component tokens: Component-specific overrides (e.g., button-primary-bg: {color-action-primary})

Accessibility as a First-Class Concern

A design system that isn't accessible is incomplete. Every component must work with screen readers, keyboard navigation, high contrast modes, and reduced motion preferences. Accessibility isn't a feature you add later—it's a constraint that shapes component architecture from day one.

Accessibility testing

Architecture and Design Patterns

Monorepo Structure

Design systems are best organized as a monorepo with multiple packages:

design-system/
├── packages/
│   ├── tokens/          # Design tokens (JSON → CSS/JS)
│   ├── core/            # Core components
│   ├── icons/           # Icon library
│   └── docs/            # Documentation site
├── apps/
│   └── storybook/       # Storybook for component development
└── package.json

Component Architecture

Each component follows a consistent structure:

// Component anatomy
interface ButtonProps {
  variant: "primary" | "secondary" | "danger";
  size: "sm" | "md" | "lg";
  disabled?: boolean;
  loading?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
  children: React.ReactNode;
}
 
// Compound components for complex UIs
interface TabsProps {
  defaultValue: string;
  children: React.ReactNode;
}
interface TabListProps { children: React.ReactNode; }
interface TabTriggerProps { value: string; children: React.ReactNode; }
interface TabPanelProps { value: string; children: React.ReactNode; }

Theming Strategy

Themes are collections of design tokens. The default theme defines the system's look and feel; custom themes override specific tokens to create branded experiences:

// Default theme
const defaultTheme = {
  colors: {
    primary: { 50: "#eff6ff", 500: "#3b82f6", 900: "#1e3a8a" },
    neutral: { 50: "#f8fafc", 500: "#64748b", 900: "#0f172a" },
  },
  spacing: { xs: "4px", sm: "8px", md: "16px", lg: "24px", xl: "32px" },
  typography: {
    fontFamily: { sans: "Inter, system-ui, sans-serif", mono: "JetBrains Mono, monospace" },
    fontSize: { xs: "12px", sm: "14px", md: "16px", lg: "18px", xl: "24px" },
  },
  radii: { sm: "4px", md: "8px", lg: "12px", full: "9999px" },
  shadows: {
    sm: "0 1px 2px rgba(0,0,0,0.05)",
    md: "0 4px 6px rgba(0,0,0,0.1)",
    lg: "0 10px 15px rgba(0,0,0,0.1)",
  },
};

Step-by-Step Implementation

Setting Up the Monorepo

mkdir design-system && cd design-system
yarn init -y
yarn add -D typescript @types/react
 
# Configure workspaces
# package.json
{
  "private": true,
  "workspaces": ["packages/*", "apps/*"],
  "scripts": {
    "build": "yarn workspaces foreach -pt run build",
    "dev": "yarn workspace @ds/docs dev",
    "storybook": "yarn workspace @ds/storybook dev",
    "test": "yarn workspaces foreach -pt run test",
    "lint": "biome check ."
  }
}

Creating Design Tokens

// packages/tokens/src/colors.ts
export const colors = {
  primary: {
    50: "#eff6ff",
    100: "#dbeafe",
    200: "#bfdbfe",
    300: "#93c5fd",
    400: "#60a5fa",
    500: "#3b82f6",
    600: "#2563eb",
    700: "#1d4ed8",
    800: "#1e40af",
    900: "#1e3a8a",
  },
  neutral: {
    50: "#f8fafc",
    100: "#f1f5f9",
    200: "#e2e8f0",
    300: "#cbd5e1",
    400: "#94a3b8",
    500: "#64748b",
    600: "#475569",
    700: "#334155",
    800: "#1e293b",
    900: "#0f172a",
  },
  semantic: {
    success: "#10b981",
    warning: "#f59e0b",
    error: "#ef4444",
    info: "#3b82f6",
  },
};
// packages/tokens/src/typography.ts
export const typography = {
  fontFamily: {
    sans: "'Inter', system-ui, -apple-system, sans-serif",
    mono: "'JetBrains Mono', 'Fira Code', monospace",
  },
  fontSize: {
    xs: ["12px", { lineHeight: "16px" }],
    sm: ["14px", { lineHeight: "20px" }],
    md: ["16px", { lineHeight: "24px" }],
    lg: ["18px", { lineHeight: "28px" }],
    xl: ["20px", { lineHeight: "28px" }],
    "2xl": ["24px", { lineHeight: "32px" }],
    "3xl": ["30px", { lineHeight: "36px" }],
  },
  fontWeight: {
    normal: "400",
    medium: "500",
    semibold: "600",
    bold: "700",
  },
};

Building a Button Component

// packages/core/src/Button/Button.tsx
import React from "react";
import { clsx } from "clsx";
import styles from "./Button.module.css";
 
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: "primary" | "secondary" | "ghost" | "danger";
  size?: "sm" | "md" | "lg";
  loading?: boolean;
  leftIcon?: React.ReactNode;
  rightIcon?: React.ReactNode;
}
 
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  (
    {
      variant = "primary",
      size = "md",
      loading = false,
      leftIcon,
      rightIcon,
      disabled,
      className,
      children,
      ...props
    },
    ref
  ) => {
    return (
      <button
        ref={ref}
        className={clsx(styles.button, styles[variant], styles[size], className)}
        disabled={disabled || loading}
        aria-busy={loading}
        {...props}
      >
        {loading && <span className={styles.spinner} aria-hidden="true" />}
        {!loading && leftIcon && <span className={styles.icon}>{leftIcon}</span>}
        <span className={styles.label}>{children}</span>
        {rightIcon && <span className={styles.icon}>{rightIcon}</span>}
      </button>
    );
  }
);
 
Button.displayName = "Button";
/* packages/core/src/Button/Button.module.css */
.button {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  font-family: var(--font-sans);
  font-weight: 600;
  border: 1px solid transparent;
  border-radius: var(--radius-md);
  cursor: pointer;
  transition: all 150ms ease;
  outline: none;
}
 
.button:focus-visible {
  box-shadow: 0 0 0 2px var(--color-primary-200), 0 0 0 4px var(--color-primary-500);
}
 
.button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
 
/* Variants */
.primary {
  background-color: var(--color-primary-500);
  color: white;
}
.primary:hover:not(:disabled) {
  background-color: var(--color-primary-600);
}
 
.secondary {
  background-color: transparent;
  border-color: var(--color-neutral-300);
  color: var(--color-neutral-700);
}
.secondary:hover:not(:disabled) {
  background-color: var(--color-neutral-50);
  border-color: var(--color-neutral-400);
}
 
.danger {
  background-color: var(--color-semantic-error);
  color: white;
}
 
/* Sizes */
.sm { font-size: 14px; padding: 6px 12px; height: 32px; }
.md { font-size: 14px; padding: 8px 16px; height: 40px; }
.lg { font-size: 16px; padding: 10px 20px; height: 48px; }

Building an Input Component with Validation

// packages/core/src/Input/Input.tsx
import React from "react";
import { clsx } from "clsx";
import styles from "./Input.module.css";
 
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "size"> {
  label: string;
  error?: string;
  hint?: string;
  size?: "sm" | "md" | "lg";
  leftAddon?: React.ReactNode;
  rightAddon?: React.ReactNode;
}
 
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, hint, size = "md", leftAddon, rightAddon, id, className, ...props }, ref) => {
    const inputId = id || `input-${label.toLowerCase().replace(/\s+/g, "-")}`;
    const errorId = `${inputId}-error`;
    const hintId = `${inputId}-hint`;
 
    return (
      <div className={clsx(styles.wrapper, className)}>
        <label htmlFor={inputId} className={styles.label}>
          {label}
          {props.required && <span className={styles.required} aria-label="required">*</span>}
        </label>
        <div className={clsx(styles.inputWrapper, styles[size], error && styles.error)}>
          {leftAddon && <span className={styles.addon}>{leftAddon}</span>}
          <input
            ref={ref}
            id={inputId}
            className={styles.input}
            aria-invalid={!!error}
            aria-describedby={clsx(error && errorId, hint && hintId) || undefined}
            {...props}
          />
          {rightAddon && <span className={styles.addon}>{rightAddon}</span>}
        </div>
        {hint && !error && (
          <p id={hintId} className={styles.hint}>{hint}</p>
        )}
        {error && (
          <p id={errorId} className={styles.errorText} role="alert">{error}</p>
        )}
      </div>
    );
  }
);
 
Input.displayName = "Input";

Building a Modal Component

// packages/core/src/Modal/Modal.tsx
import React from "react";
import { createPortal } from "react-dom";
import { clsx } from "clsx";
import styles from "./Modal.module.css";
 
export interface ModalProps {
  open: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
  size?: "sm" | "md" | "lg";
}
 
export function Modal({ open, onClose, title, children, size = "md" }: ModalProps) {
  const overlayRef = React.useRef<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);
 
  React.useEffect(() => {
    if (!open) return;
 
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === "Escape") onClose();
    };
 
    document.addEventListener("keydown", handleEscape);
    document.body.style.overflow = "hidden";
 
    return () => {
      document.removeEventListener("keydown", handleEscape);
      document.body.style.overflow = "";
    };
  }, [open, onClose]);
 
  React.useEffect(() => {
    if (open && contentRef.current) {
      contentRef.current.focus();
    }
  }, [open]);
 
  if (!open) return null;
 
  return createPortal(
    <div
      ref={overlayRef}
      className={styles.overlay}
      onClick={(e) => {
        if (e.target === overlayRef.current) onClose();
      }}
      role="dialog"
      aria-modal="true"
      aria-label={title}
    >
      <div ref={contentRef} className={clsx(styles.content, styles[size])} tabIndex={-1}>
        <div className={styles.header}>
          <h2 className={styles.title}>{title}</h2>
          <button
            className={styles.closeButton}
            onClick={onClose}
            aria-label="Close dialog"
          >
            ×
          </button>
        </div>
        <div className={styles.body}>{children}</div>
      </div>
    </div>,
    document.body
  );
}

Component library

Real-World Use Cases

Startup MVP

A startup building a SaaS product created a minimal design system with 12 components (Button, Input, Select, Modal, Card, Table, Toast, Tabs, Badge, Avatar, Dropdown, Pagination). The system used CSS Modules with design tokens in CSS custom properties. The total investment was 2 weeks of engineering time, but it saved 30% of frontend development time on subsequent features.

Enterprise Multi-Product Suite

An enterprise with 5 products built a shared design system with 45 components, 3 themes (brand A, brand B, brand C), and full accessibility compliance. The system was published as a private npm package with a Storybook documentation site. The investment paid for itself within 6 months through reduced design inconsistencies and faster feature development.

Open Source Library

An open source project created a design system as a headless component library (unstyled components with full accessibility) plus a styled theme package. This approach allowed community members to create custom themes without forking the component logic.

Best Practices for Production

  1. Start with tokens, not components — Define your color palette, typography scale, and spacing scale first. These decisions affect every component.

  2. Use CSS custom properties for theming — CSS variables enable runtime theme switching without JavaScript overhead. Define tokens as CSS custom properties at the :root level.

  3. Build accessibility into every component — Use ARIA attributes, keyboard navigation, focus management, and screen reader testing from day one. Retroactively adding accessibility is 10x harder.

  4. Write Storybook stories for every component — Stories serve as documentation, visual regression tests, and a development environment. Each component should have stories for all variants and states.

  5. Use compound components for complex UIs — Components like Tabs, Accordion, and Menu should expose a compound component API that gives consumers control over composition.

  6. Version your design system semantically — Breaking changes to component APIs require major version bumps. Use changesets or conventional commits to automate versioning.

  7. Provide codemods for breaking changes — When a component API changes, provide a codemod that automatically updates consumer code. This reduces the upgrade burden.

  8. Test with real users — Run usability tests with developers who consume your system. Their feedback on API ergonomics is invaluable.

Common Pitfalls and Solutions

PitfallImpactSolution
Over-engineering earlySlow progress, abandoned systemStart with 5-10 core components, expand based on actual needs
Designer-developer disconnectInconsistent implementationUse shared tokens and Figma plugins that sync with code
No documentationLow adoptionInvest in Storybook with usage examples and API docs
Tight coupling to frameworkMigration difficultyKeep components framework-agnostic where possible, use adapters
Accessibility afterthoughtLegal risk, exclusionAudit every component with axe-core and screen reader testing
No versioning strategyBreaking consumer buildsUse semantic versioning with automated changelog generation

Performance Optimization

Tree Shaking

Design systems should be fully tree-shakable. Use named exports and avoid barrel files that re-export everything:

// Good: direct imports (tree-shakable)
import { Button } from "@ds/core/Button";
import { Input } from "@ds/core/Input";
 
// Bad: barrel import (may not tree-shake)
import { Button, Input } from "@ds/core";

CSS-in-JS vs CSS Modules

ApproachBundle SizeRuntime CostThemingDX
CSS ModulesSmallestNoneCSS varsGood
styled-componentsMediumRuntimeTheme providerExcellent
Tailwind + tokensSmallNoneConfigGood
Vanilla ExtractSmallestNoneSprinklesExcellent

Comparison with Alternatives

ApproachCustomizationMaintenanceAccessibilityLearning Curve
Build from scratchFull controlHighYour responsibilityHigh
Headless library (Radix)Full styling controlLowBuilt-inMedium
Component library (MUI)Theme customizationMediumBuilt-inLow
CSS framework (Tailwind)Utility classesLowManualMedium

Advanced Patterns

Animation System

// packages/core/src/utils/animations.ts
export const motion = {
  fadeIn: {
    initial: { opacity: 0 },
    animate: { opacity: 1 },
    exit: { opacity: 0 },
    transition: { duration: 0.2 },
  },
  slideUp: {
    initial: { opacity: 0, y: 20 },
    animate: { opacity: 1, y: 0 },
    exit: { opacity: 0, y: 20 },
    transition: { duration: 0.3, ease: "easeOut" },
  },
  scale: {
    initial: { opacity: 0, scale: 0.95 },
    animate: { opacity: 1, scale: 1 },
    exit: { opacity: 0, scale: 0.95 },
    transition: { duration: 0.2 },
  },
};

Responsive Utilities

// packages/core/src/hooks/useMediaQuery.ts
export function useMediaQuery(query: string): boolean {
  const [matches, setMatches] = React.useState(
    () => window.matchMedia(query).matches
  );
 
  React.useEffect(() => {
    const mql = window.matchMedia(query);
    const handler = (e: MediaQueryListEvent) => setMatches(e.matches);
    mql.addEventListener("change", handler);
    return () => mql.removeEventListener("change", handler);
  }, [query]);
 
  return matches;
}
 
// Usage
const isMobile = useMediaQuery("(max-width: 768px)");

Testing Strategies

Visual Regression with Chromatic

// Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";
 
const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
  argTypes: {
    variant: { control: "select", options: ["primary", "secondary", "ghost", "danger"] },
    size: { control: "select", options: ["sm", "md", "lg"] },
  },
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
export const Primary: Story = { args: { variant: "primary", children: "Button" } };
export const Secondary: Story = { args: { variant: "secondary", children: "Button" } };
export const Loading: Story = { args: { loading: true, children: "Saving..." } };
export const Disabled: Story = { args: { disabled: true, children: "Disabled" } };

Accessibility Testing

// __tests__/Button.a11y.test.tsx
import { render, screen } from "@testing-library/react";
import { axe, toHaveNoViolations } from "jest-axe";
import { Button } from "../Button";
 
expect.extend(toHaveNoViolations);
 
describe("Button accessibility", () => {
  it("has no accessibility violations", async () => {
    const { container } = render(<Button>Click me</Button>);
    expect(await axe(container)).toHaveNoViolations();
  });
 
  it("announces loading state to screen readers", () => {
    render(<Button loading>Loading</Button>);
    expect(screen.getByRole("button")).toHaveAttribute("aria-busy", "true");
  });
 
  it("is focusable via keyboard", () => {
    render(<Button>Focus me</Button>);
    const button = screen.getByRole("button");
    button.focus();
    expect(button).toHaveFocus();
  });
});

Future Outlook

Design systems are evolving toward design-to-code automation. Tools like Figma's Dev Mode and Anima generate component code directly from design files. AI-powered tools can suggest component variants based on design patterns. The gap between design and implementation is narrowing.

Headless component libraries (Radix, React Aria, Headless UI) are becoming the default foundation. Teams build their styled components on top of these accessible primitives, rather than implementing ARIA patterns from scratch. This reduces the accessibility burden significantly.

Token standards are converging. The W3C Design Tokens Community Group is defining a standard JSON format for design tokens that tools like Figma, Style Dictionary, and CSS preprocessors can all consume natively. This will enable true interoperability between design tools and code.

Conclusion

Building a design system from scratch is a marathon, not a sprint. The key takeaways:

  1. Start with tokens — Define your color, typography, and spacing scales before building any components
  2. Accessibility is non-negotiable — Build it into every component from day one; retrofitting is exponentially harder
  3. Documentation drives adoption — Storybook stories with usage examples are the primary developer-facing API
  4. Start small, grow organically — 10 well-built components beat 50 mediocre ones
  5. Governance matters — Establish contribution guidelines, versioning strategy, and breaking change policies early

Begin by auditing your existing UI. Identify the 5 most duplicated components in your codebase and build those first. Measure adoption by tracking how many new features use system components vs. ad-hoc implementations. The ROI becomes clear quickly.