Web Engineering ·

Building a Design System in React: Component APIs, Token-Based Theming, and Automated Visual Regression Testing

A practical guide to building a production React design system from scratch, covering component API design, design token architecture, Storybook integration, visual regression testing, and versioning strategy for growing teams.

Building a Design System in React: Component APIs, Token-Based Theming, and Automated Visual Regression Testing

Most teams build a design system the same way: one engineer extracts a Button component, then someone adds a Card, and six months later you have forty loosely related components with three different spacing scales, two color APIs, and no tests. The system exists but it does not cohere.

Building a design system that scales with the team requires upfront decisions about three things: how component APIs are structured, how visual design decisions are encoded as data, and how you prevent regressions when either changes. This guide covers all three in sequence, with the TypeScript patterns that make each layer work in practice.

Component API Design

The component API is the contract between the design system and every team that uses it. A poor API forces consumers to reach into implementation details; a good one constrains misuse while enabling composition.

Compound Components

Compound components split a conceptually unified UI element across multiple components that share implicit state. They give consumers control over structure without exposing internal state management directly.

import React, { createContext, useContext, useState } from "react";

interface AccordionContextValue {
  openItems: Set<string>;
  toggle: (id: string) => void;
}

const AccordionContext = createContext<AccordionContextValue | null>(null);

function useAccordion() {
  const ctx = useContext(AccordionContext);
  if (!ctx) throw new Error("useAccordion must be used within Accordion");
  return ctx;
}

interface AccordionProps {
  children: React.ReactNode;
  defaultOpen?: string[];
  multiple?: boolean;
}

function Accordion({ children, defaultOpen = [], multiple = false }: AccordionProps) {
  const [openItems, setOpenItems] = useState<Set<string>>(new Set(defaultOpen));

  const toggle = (id: string) => {
    setOpenItems((prev) => {
      const next = new Set(prev);
      if (next.has(id)) {
        next.delete(id);
      } else {
        if (!multiple) next.clear();
        next.add(id);
      }
      return next;
    });
  };

  return (
    <AccordionContext.Provider value={{ openItems, toggle }}>
      <div>{children}</div>
    </AccordionContext.Provider>
  );
}

interface AccordionItemProps {
  id: string;
  children: React.ReactNode;
}

function AccordionItem({ id, children }: AccordionItemProps) {
  return <div data-accordion-item={id}>{children}</div>;
}

interface AccordionTriggerProps {
  id: string;
  children: React.ReactNode;
}

function AccordionTrigger({ id, children }: AccordionTriggerProps) {
  const { openItems, toggle } = useAccordion();
  const isOpen = openItems.has(id);

  return (
    <button
      onClick={() => toggle(id)}
      aria-expanded={isOpen}
      type="button"
    >
      {children}
    </button>
  );
}

interface AccordionContentProps {
  id: string;
  children: React.ReactNode;
}

function AccordionContent({ id, children }: AccordionContentProps) {
  const { openItems } = useAccordion();
  if (!openItems.has(id)) return null;
  return <div role="region">{children}</div>;
}

Accordion.Item = AccordionItem;
Accordion.Trigger = AccordionTrigger;
Accordion.Content = AccordionContent;

export { Accordion };

Consumer code becomes explicit about structure without managing the open/close state itself:

<Accordion multiple>
  <Accordion.Item id="first">
    <Accordion.Trigger id="first">Section One</Accordion.Trigger>
    <Accordion.Content id="first">Content here.</Accordion.Content>
  </Accordion.Item>
</Accordion>

The tradeoff is verbosity at the call site. For simple cases a flat <Accordion items={...} /> is faster to type. Compound components earn their complexity when consumers need structural control: inserting icons, badges, or loading states between trigger and content.

Polymorphic Components

Polymorphic components render as a different HTML element or React component depending on a prop. The classic case is a Text component that renders as p, h1, span, or a router Link without duplicating logic.

The TypeScript for this is the part most implementations get wrong:

type AsProp<C extends React.ElementType> = {
  as?: C;
};

type PropsToOmit<C extends React.ElementType, P> = keyof (AsProp<C> & P);

type PolymorphicComponentProps<
  C extends React.ElementType,
  Props = object
> = React.PropsWithChildren<Props & AsProp<C>> &
  Omit<React.ComponentPropsWithoutRef<C>, PropsToOmit<C, Props>>;

type PolymorphicRef<C extends React.ElementType> =
  React.ComponentPropsWithRef<C>["ref"];

interface TextOwnProps {
  size?: "sm" | "md" | "lg";
  weight?: "regular" | "medium" | "bold";
  color?: "primary" | "secondary" | "muted";
}

type TextProps<C extends React.ElementType> = PolymorphicComponentProps<C, TextOwnProps>;

function Text<C extends React.ElementType = "p">({
  as,
  size = "md",
  weight = "regular",
  color = "primary",
  children,
  ...rest
}: TextProps<C>) {
  const Component = as ?? "p";
  return (
    <Component
      className={`text-${size} font-${weight} color-${color}`}
      {...rest}
    >
      {children}
    </Component>
  );
}

With this pattern <Text as="h1"> correctly inherits h1’s props (including aria-level), while <Text as={Link}> inherits the router Link’s href or to prop. TypeScript will error if you pass a prop that does not exist on the resolved element type.

forwardRef Patterns

Design system components almost always need to expose their underlying DOM ref. Without forwardRef, consumers cannot control focus, measure layout, or integrate with third-party libraries like Floating UI or DnD Kit.

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string;
  error?: string;
  hint?: string;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, hint, id, ...rest }, ref) => {
    const inputId = id ?? React.useId();
    const errorId = `${inputId}-error`;
    const hintId = `${inputId}-hint`;

    return (
      <div>
        <label htmlFor={inputId}>{label}</label>
        <input
          ref={ref}
          id={inputId}
          aria-describedby={[hint && hintId, error && errorId]
            .filter(Boolean)
            .join(" ") || undefined}
          aria-invalid={!!error}
          {...rest}
        />
        {hint && <p id={hintId}>{hint}</p>}
        {error && <p id={errorId} role="alert">{error}</p>}
      </div>
    );
  }
);

Input.displayName = "Input";

Setting displayName explicitly is not optional. Without it, React DevTools shows ForwardRef everywhere and debugging becomes painful.

Design Token Architecture

Design tokens are named values that represent design decisions: colors, spacing, typography, border radii, shadows. The architecture question is not whether to use tokens but where they live and how semantic layers map to primitive layers.

Primitive and Semantic Tokens

Primitive tokens name raw values. Semantic tokens name the role. Never use a primitive token directly in component code.

// tokens/primitive.ts — raw values, no context
export const primitives = {
  color: {
    blue50: "#eff6ff",
    blue500: "#3b82f6",
    blue900: "#1e3a8a",
    gray100: "#f3f4f6",
    gray500: "#6b7280",
    gray900: "#111827",
    white: "#ffffff",
    red500: "#ef4444",
  },
  space: {
    1: "0.25rem",
    2: "0.5rem",
    4: "1rem",
    8: "2rem",
    16: "4rem",
  },
  radius: {
    sm: "0.25rem",
    md: "0.375rem",
    lg: "0.5rem",
    full: "9999px",
  },
} as const;

// tokens/semantic.ts — contextual meaning
export const semantic = {
  color: {
    background: {
      default: primitives.color.white,
      subtle: primitives.color.gray100,
      interactive: primitives.color.blue500,
      interactiveHover: primitives.color.blue900,
      danger: primitives.color.red500,
    },
    text: {
      default: primitives.color.gray900,
      muted: primitives.color.gray500,
      onInteractive: primitives.color.white,
    },
    border: {
      default: primitives.color.gray100,
    },
  },
} as const;

When you swap themes or update a brand color, you update the semantic mapping, not every component.

CSS Custom Properties for Runtime Theming

Theme objects work at build time. CSS custom properties are the right mechanism for runtime theming, because they cascade through the DOM and change instantly without a re-render.

/* tokens/base.css */
:root {
  --color-bg-default: #ffffff;
  --color-bg-subtle: #f3f4f6;
  --color-bg-interactive: #3b82f6;
  --color-bg-interactive-hover: #1e3a8a;
  --color-bg-danger: #ef4444;

  --color-text-default: #111827;
  --color-text-muted: #6b7280;
  --color-text-on-interactive: #ffffff;

  --color-border-default: #f3f4f6;

  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --space-8: 2rem;

  --radius-sm: 0.25rem;
  --radius-md: 0.375rem;
  --radius-lg: 0.5rem;
  --radius-full: 9999px;
}

/* Dark mode by media query — no JavaScript required */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-default: #111827;
    --color-bg-subtle: #1f2937;
    --color-bg-interactive: #3b82f6;
    --color-bg-interactive-hover: #60a5fa;
    --color-text-default: #f9fafb;
    --color-text-muted: #9ca3af;
    --color-border-default: #374151;
  }
}

/* User-overridden theme via data attribute */
[data-theme="dark"] {
  --color-bg-default: #111827;
  --color-bg-subtle: #1f2937;
  --color-text-default: #f9fafb;
  --color-text-muted: #9ca3af;
  --color-border-default: #374151;
}

Components then reference semantic tokens, never primitives:

.button-primary {
  background-color: var(--color-bg-interactive);
  color: var(--color-text-on-interactive);
  border-radius: var(--radius-md);
  padding: var(--space-2) var(--space-4);
}

.button-primary:hover {
  background-color: var(--color-bg-interactive-hover);
}

The data-theme attribute approach gives you explicit user control on top of the media query default. To switch themes:

function ThemeToggle() {
  const [theme, setTheme] = React.useState<"light" | "dark">("light");

  const toggle = () => {
    const next = theme === "light" ? "dark" : "light";
    setTheme(next);
    document.documentElement.setAttribute("data-theme", next);
  };

  return <button onClick={toggle}>Toggle theme</button>;
}
ApproachRuntime switchingSSR safeBundle costComplexity
CSS custom propertiesYesYesZeroLow
Theme context + CSS-in-JSYesRequires hydration careMediumMedium
Static CSS classesNoYesZeroLow
Inline styles from theme objectYesYesZeroMedium

CSS custom properties win for most design systems. The main downside is that you cannot query a token value in JavaScript without getComputedStyle. If you need token values in JavaScript (for canvas rendering, for example), maintain a parallel TypeScript object and accept the duplication.

Building the Component Library with Storybook

Storybook is the right tool for developing components in isolation. The key is writing stories that serve as documentation and test fixtures, not just visual demos.

// Button.stories.tsx
import type { Meta, StoryObj } from "@storybook/react";
import { Button } from "./Button";

const meta: Meta<typeof Button> = {
  title: "Components/Button",
  component: Button,
  tags: ["autodocs"],
  argTypes: {
    variant: {
      control: "select",
      options: ["primary", "secondary", "ghost", "danger"],
    },
    size: {
      control: "radio",
      options: ["sm", "md", "lg"],
    },
    disabled: { control: "boolean" },
  },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: { children: "Save changes", variant: "primary", size: "md" },
};

export const Danger: Story = {
  args: { children: "Delete account", variant: "danger", size: "md" },
};

export const LoadingState: Story = {
  args: { children: "Saving...", variant: "primary", isLoading: true },
};

export const AllVariants: Story = {
  render: () => (
    <div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
      {(["primary", "secondary", "ghost", "danger"] as const).map((v) => (
        <Button key={v} variant={v}>{v}</Button>
      ))}
    </div>
  ),
};

The tags: ["autodocs"] flag generates a documentation page automatically from TypeScript types and JSDoc comments. Write JSDoc on every prop; it shows up in the docs table with zero additional effort.

Automated Visual Regression Testing

Unit tests verify behavior. Visual regression tests verify appearance. The two are complementary and neither substitutes for the other.

The workflow: each story becomes a snapshot. On every pull request, a CI step renders all stories and compares screenshots pixel-by-pixel against a stored baseline. Any pixel difference produces a review task; a human approves intentional changes and rejects accidents.

Chromatic (built by the Storybook team) integrates with the least configuration:

# .github/workflows/chromatic.yml
name: Chromatic

on: [push]

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          cache: npm
      - run: npm ci
      - uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          exitZeroOnChanges: true

exitZeroOnChanges: true means the CI step does not fail when there are visual changes; it sends them to Chromatic for human review instead of blocking the build. Set it to false if you want strict mode where any unreviewed change blocks merging.

For teams who prefer self-hosted tooling, @percy/cli with Playwright captures screenshots without an external service, at the cost of maintaining baseline storage and a diff UI.

The underappreciated part of visual regression testing is story coverage. A test is only as useful as the state it captures. Write stories for:

  • Every variant and size combination
  • Hover, focus, and active states (use play functions with userEvent)
  • Error and loading states
  • Long text that might overflow
  • Truncation scenarios
  • Dark mode (use a Storybook theme decorator)
// Button.stories.tsx — play function for interaction states
export const FocusedState: Story = {
  args: { children: "Focused button", variant: "primary" },
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole("button");
    await userEvent.tab();
    expect(button).toHaveFocus();
  },
};

Versioning and Publishing Strategy

A design system is a library with multiple consumers. Breaking changes are expensive: every consuming team has to update. The tooling should make that cost explicit before a release ships.

Changesets handles this well. Each pull request that changes the public API includes a changeset file describing what changed and whether it is a major, minor, or patch change:

npx changeset

This prompts the author to select the affected packages, the bump type, and write a summary. On merge to main, a Changesets GitHub Action opens a “Version Packages” PR that aggregates all pending changesets into a CHANGELOG and bumps version numbers:

# .github/workflows/release.yml
name: Release

on:
  push:
    branches: [main]

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 20
          registry-url: https://registry.npmjs.org
      - run: npm ci
      - uses: changesets/action@v1
        with:
          publish: npm run release
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

For monorepos with multiple packages (tokens, components, icons), Changesets tracks inter-package dependencies and bumps downstream packages automatically when an upstream package has a breaking change.

Semantic versioning rules for a design system differ slightly from a backend library:

  • Major: any change that breaks an existing consumer without code changes (removed props, changed prop types, behavioral changes to existing variants)
  • Minor: new components, new props with defaults, new token names
  • Patch: bug fixes, documentation updates, internal refactors with no API change

A visual change without an API change can still be a major bump if it breaks a consumer’s design. Document this policy in your CONTRIBUTING guide so contributors know how to classify their changeset.

Adoption Patterns for Growing Teams

A design system only creates value when teams use it consistently. The biggest adoption failure mode is building a system in isolation and then announcing it, expecting adoption to follow. It does not.

Patterns that work:

Colocate the system with the product. Keep the design system in the same monorepo as the main application, at least initially. When a product engineer needs a component that does not exist, the cost of adding it to the system is the same as adding it to the product. Premature extraction to a separate repo creates a contribution barrier before there is enough momentum to sustain it.

Versioned “upgrade guides” for breaking changes. When you ship a major version, write a migration guide with codemods where possible. A breaking change with a two-line codemod is experienced as a minor inconvenience; a breaking change with three hours of manual search-and-replace is experienced as a reason to stop upgrading.

Lint rules that enforce token usage. An ESLint plugin that warns when a hex color appears inline instead of a token catches drift before it reaches code review:

// eslint-plugin-design-tokens/no-raw-colors.ts
import type { Rule } from "eslint";

const noRawColors: Rule.RuleModule = {
  meta: {
    type: "suggestion",
    messages: {
      useToken: "Use a design token instead of a raw color value '{{ value }}'.",
    },
    schema: [],
  },
  create(context) {
    const colorPattern = /#([0-9a-fA-F]{3,8})\b|rgb\(|hsl\(/;
    return {
      Literal(node) {
        if (typeof node.value === "string" && colorPattern.test(node.value)) {
          context.report({
            node,
            messageId: "useToken",
            data: { value: node.value },
          });
        }
      },
    };
  },
};

export default noRawColors;

Usage analytics before deprecation. Before removing a component or renaming a prop, run a codemod scan across the consuming repositories to count actual usage. Removing a component used in two places and one used in forty requires different communication, different lead time, and different migration support.

The Real Constraint

Every design system starts as an act of optimism: one set of components, used consistently, maintained centrally. The hard part is not the technology. CSS custom properties work fine for theming. Compound components are not complicated to implement. Changesets is straightforward to configure.

The constraint is governance: who decides when a component is “done enough” to add, who owns the breaking-change decision, and what the process is when a product team needs something the system does not provide. Design systems that answer those questions early, even imperfectly, outlast the ones that only answer the technical questions.

Start with the token layer. Get the semantic names right before writing a single component. Every future decision about theming, dark mode, and brand changes flows from whether the primitives are cleanly separated from the semantics. The components can always be refactored; a poorly named token gets copied into three hundred files before anyone notices.

More in Web Engineering

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement
Web Engineering ·

How Rspack Works Internally: Webpack-Compatible Module Graph, Rust Compilation Pipeline, and the Incremental Build Architecture Behind the Fastest Webpack Replacement

A deep dive into Rspack's Rust-based architecture, module graph construction, SWC transformation pipeline, webpack compatibility layer, incremental compilation, and what the tradeoffs look like for teams migrating from webpack.

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js
Web Engineering ·

How Turbopack Works Internally: Incremental Computation, Function-Level Caching, and the Rust-Based Bundler Architecture Behind Next.js

A deep dive into Turbopack's architecture covering the Turbo engine's incremental computation model, function-level caching, Rust-based module resolution, granular HMR invalidation, SWC integration, persistent caching, and how it compares to webpack, Vite, esbuild, and Rspack.

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format
Web Engineering ·

How gRPC Works: Protocol Buffers, HTTP/2 Multiplexing, and Bidirectional Streaming From Service Definition to Wire Format

A deep dive into gRPC internals: Protocol Buffer IDL and the buf codegen pipeline, HTTP/2 stream multiplexing and HPACK header compression, all four RPC patterns with TypeScript, channel management, deadline propagation, interceptors, load balancing from pick-first to xDS, the health checking protocol, and production tradeoffs vs REST and GraphQL.

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit
Web Engineering ·

How Node.js Works Internally: The Event Loop, libuv Thread Pool, and Async I/O Architecture From require() to Process Exit

A deep dive into Node.js internals covering V8 JIT compilation, the six-phase libuv event loop, thread pool mechanics, microtask queue priority, async/await desugaring, CommonJS versus ESM module resolution, and production tuning considerations with TypeScript examples.