Home/Blog

How to Build a Design System and Component Library

How to build a design system teams adopt: token tiers, component APIs, packaging for React Server Components, versioning, accessibility and visual tests.

How to Build a Design System and Component Library

A design system that teams actually adopt has three parts: design tokens that encode every visual decision in layers, a small set of accessible components with consistent APIs, and a release process that lets product teams upgrade without fear. The component library is the easy part. The hard parts are the decisions around it (who owns the tokens, what counts as a breaking change, how a team contributes a missing component) and the packaging details that decide whether the library works in a modern React app at all. Build on headless accessible primitives, publish per-component entry points, and test every component for accessibility and visual regressions on every pull request.

Key takeaways

  • Layer tokens: raw palette, semantic aliases, component tokens. Themes override only the semantic layer.
  • Build visuals on headless primitives (Radix Primitives, React Aria Components) instead of writing focus management and keyboard handling yourself.
  • Keep component APIs boring and consistent: one prop vocabulary, composition over configuration, asChild for polymorphism, refs passed through.
  • Package for React Server Components: per-component entry points, "use client" preserved where needed, React as a peer dependency.
  • Every pull request runs accessibility checks and visual regression tests against stories. Every major release ships a codemod.

Build, adopt or copy: pick the starting point

The first decision is how much you write yourself. It decides your maintenance load for years.

ApproachExamplesGood fitWhat it costs you
Adopt a full libraryMUI, Mantine, Chakra UIInternal tools, admin panels, teams with no designerYour product looks like everyone else's, and deep theming fights the library
Headless primitives plus your stylesRadix Primitives, React Aria Components, Base UIProduct companies with their own brand and a designerYou own every visual, but accessibility behavior comes tested
Copy-in componentsshadcn/uiTailwind codebases that want ownership from day oneNo upgrades for free: once copied, fixes upstream do not reach you
Fully customYour own everythingUnusual platforms or constraints no primitive meetsComboboxes, menus, dialogs and date pickers are far more work than they look

For most product teams, headless primitives plus your own styles is the right default. Visual identity is the part you should own. Keyboard navigation, focus trapping, aria- state management and screen reader announcements are the parts you should not reinvent.

The architecture: four layers

LayerContentsWho changes it
TokensColor, spacing, type scale, radii, shadows, motion, breakpointsDesign system team, through review
Core componentsButton, Input, Select, Checkbox, Dialog, Tooltip, BadgeDesign system team, contributions welcome
Composite componentsDataTable, DatePicker, FormField, CommandPaletteShared ownership, often contributed by product teams
PatternsPage layouts, empty states, form layouts, navigationDocumented as guidance, not shipped as rigid components

The bottom two layers should be strict. The top layer should be guidance with examples, because a rigid "page template" component turns into a prop explosion the moment a second team uses it.

Design tokens: three tiers

Tokens replace hard-coded values with names. The naming is what makes theming possible, so tier them:

  1. Primitive tokens are the raw palette and scales: blue-500, space-4. Components never use them directly.
  2. Semantic tokens describe purpose: color-action, color-danger, color-bg, color-fg. Themes and dark mode override this tier only.
  3. Component tokens scope a semantic value to one component: button-bg, button-px. They let one component be adjusted without touching the rest.

If you only ship to the web, CSS custom properties are a perfectly good token format:

/* Tier 1: primitives. Components never reference these directly. */
:root {
  --blue-500: #3b82f6;
  --blue-600: #2563eb;
  --zinc-50: #fafafa;
  --zinc-950: #09090b;
  --space-2: 0.5rem;
  --space-4: 1rem;
  --radius-md: 0.5rem;
}
 
/* Tier 2: semantic aliases. Themes override only this tier. */
:root {
  --color-bg: var(--zinc-50);
  --color-fg: var(--zinc-950);
  --color-action: var(--blue-600);
  --color-action-hover: var(--blue-500);
}
 
[data-theme="dark"] {
  --color-bg: var(--zinc-950);
  --color-fg: var(--zinc-50);
  --color-action: var(--blue-500);
  --color-action-hover: var(--blue-600);
}
 
/* Tier 3: component tokens, scoped to one component. */
.ds-button {
  --button-bg: var(--color-action);
  --button-px: var(--space-4);
  background: var(--button-bg);
  color: white;
  padding-inline: var(--button-px);
  border-radius: var(--radius-md);
}
 
.ds-button:hover {
  --button-bg: var(--color-action-hover);
}

When a second platform arrives (iOS, Android, email templates), move the source of truth to JSON and generate each platform's output with a pipeline such as Style Dictionary. The W3C Design Tokens Community Group format, with its $value and $type keys, is the interchange format to target, because design tools and token pipelines increasingly read it. If you use Tailwind CSS v4, map the semantic tier into its @theme block so utilities resolve to your tokens rather than to raw palette values.

Two token rules save a lot of pain later. Validate contrast for token pairs, not single colors: color-fg on color-bg must meet WCAG AA in every theme, which means 4.5:1 for normal text and 3:1 for large text. And never let a component reach for a primitive: the day you rebrand, every --blue-600 buried in component CSS is a manual fix.

Component API design

A component's API is a contract with every product engineer who uses it. Consistent, composable and hard to misuse beats clever.

One prop vocabulary. If Button uses variant and size with sm, md, lg, every sized component uses the same names and values. Write the vocabulary down. Review new components against it.

Composition over configuration. Prefer sub-components and children to configuration objects. Configuration props grow without bound because every new case needs a new option.

// Composition: each part is replaceable and readable.
<Card>
  <Card.Header>
    <Card.Title>Usage this month</Card.Title>
    <Card.Description>Resets on the 1st</Card.Description>
  </Card.Header>
  <Card.Content>{children}</Card.Content>
  <Card.Footer>
    <Button variant="secondary">Cancel</Button>
    <Button>Upgrade</Button>
  </Card.Footer>
</Card>
 
// Configuration: every new requirement becomes another prop.
<Card
  title="Usage this month"
  description="Resets on the 1st"
  footerActions={[{ label: "Cancel", variant: "secondary" }, { label: "Upgrade" }]}
/>

Polymorphism with asChild, refs passed through, safe defaults. Here is a Button that covers the details product teams trip over:

import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import type { ComponentPropsWithRef } from "react";
 
const button = cva("ds-button", {
  variants: {
    variant: {
      primary: "ds-button--primary",
      secondary: "ds-button--secondary",
      ghost: "ds-button--ghost",
    },
    size: { sm: "ds-button--sm", md: "ds-button--md", lg: "ds-button--lg" },
  },
  defaultVariants: { variant: "primary", size: "md" },
});
 
type ButtonProps = ComponentPropsWithRef<"button"> &
  VariantProps<typeof button> & { asChild?: boolean };
 
// React 19 passes ref as an ordinary prop, so no forwardRef wrapper is needed.
// A plain button inside a form submits it, so default type to "button".
export function Button({ asChild = false, variant, size, className, type, ...props }: ButtonProps) {
  const Comp = asChild ? Slot : "button";
  return (
    <Comp
      type={asChild ? type : (type ?? "button")}
      className={button({ variant, size, className })}
      {...props}
    />
  );
}

asChild (from Radix's Slot) lets a consumer render Button styles and behavior on their router's link without the library knowing about the router: <Button asChild><Link href="/billing">Billing</Link></Button>. Spreading the remaining props means aria-*, data-* and event handlers just work, which removes the most common reason teams wrap or fork a component.

Packaging: where libraries break in real apps

Most "the design system does not work in our app" reports are packaging bugs, not component bugs.

{
  "name": "@acme/ui",
  "version": "4.2.0",
  "type": "module",
  "sideEffects": ["**/*.css"],
  "exports": {
    "./button": { "types": "./dist/button.d.ts", "import": "./dist/button.js" },
    "./dialog": { "types": "./dist/dialog.d.ts", "import": "./dist/dialog.js" },
    "./styles.css": "./dist/styles.css"
  },
  "peerDependencies": {
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  }
}

What each line prevents:

  • Per-component entry points instead of one barrel file. A barrel makes every consumer parse and often bundle the whole library to get a Button.
  • sideEffects limited to CSS. It tells bundlers that unused JavaScript modules can be dropped, while keeping your stylesheet imports.
  • React as a peer dependency. A library that bundles or pins its own React gives the app two copies, and hooks fail with "Invalid hook call".
  • "use client" preserved. In a Next.js App Router app, any component that uses state, effects or context must be a Client Component. If your bundler strips the directive from the top of each entry file, consumers get a build error the moment they import your Dialog from a Server Component. Check the built files, not the source.
  • Zero-runtime styling. CSS-in-JS libraries that inject styles through React context at runtime need Client Components and extra server setup. Plain CSS, CSS Modules, or Tailwind classes avoid that entirely.

A monorepo is the natural home for all of this: tokens, UI, icons, docs and a playground app in one repository, built in dependency order, released independently.

packages/
  tokens/        @acme/tokens   (CSS variables, plus JSON when you go multi-platform)
  ui/            @acme/ui       (components, one entry point per component)
  icons/         @acme/icons
apps/
  docs/          Storybook, the living documentation
  playground/    a real app that imports the built packages

The playground matters. It imports the published build output the way a product app does, so packaging bugs show up in your CI instead of in someone else's.

Accessibility is the system's job

A design system is the highest-leverage place to get accessibility right: fix a Dialog once and every product surface inherits the fix. The minimum bar for each component:

  • Keyboard operable. Every interactive element reachable and usable without a mouse, with focus moved into dialogs on open and returned to the trigger on close.
  • Correct roles and states. Use the headless primitive's ARIA wiring instead of hand-rolling it.
  • Visible focus. Never remove the outline without a replacement that meets 3:1 contrast against adjacent colors (WCAG 1.4.11). WCAG 2.2 also requires that focused elements are not completely hidden behind sticky headers or other author content.
  • Target size. WCAG 2.2 AA asks for pointer targets of at least 24 by 24 CSS pixels, or enough spacing around smaller ones. Icon buttons are the usual offenders.
  • Reduced motion. Respect prefers-reduced-motion in the token layer, so every animation inherits it.
:root {
  --motion-duration: 150ms;
}
 
@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-duration: 0ms;
  }
}

Automated checks catch missing labels, contrast failures and invalid ARIA. They do not tell you whether a screen reader user can complete a task, so test core components with VoiceOver and NVDA before each major release. Our web accessibility guide covers the testing workflow in depth.

Testing: stories are the test fixtures

Write one story per meaningful state (default, each variant, disabled, loading, error, long content, right-to-left if you support it). Then reuse those stories as fixtures for automated checks, so documentation and tests cannot drift apart.

import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
 
const stories = ["overlay-dialog--open", "forms-select--with-error", "data-datatable--long-content"];
 
for (const id of stories) {
  test(`story ${id} is accessible and visually stable`, async ({ page }) => {
    await page.goto(`/iframe.html?id=${id}&viewMode=story`);
    const results = await new AxeBuilder({ page }).analyze();
    expect(results.violations).toEqual([]);
    await expect(page).toHaveScreenshot(`${id}.png`);
  });
}

Run it against a static Storybook build in CI. Screenshot tests need a fixed browser version, fixed fonts and a fixed viewport, or they flake on rendering noise. A hosted service such as Chromatic handles that for you; Playwright's toHaveScreenshot works if you pin the environment in a container. Interaction tests through Storybook play functions cover the behavior side: open the menu, press the arrow keys, assert the active option. Our testing strategies guide shows where these fit alongside unit and end-to-end tests.

Screenshot diffs at one width miss the most common responsive bug: a composite component that is fine on a laptop and wider than a phone. A DataTable with a min-width or a chart with a fixed pixel width pushes the whole page sideways at 375 pixels. wontfit, our open-source checker, loads pages of a running app at phone and tablet widths, names the element that overflows, and fails the build when one does, so pointing it at your docs app's component pages turns that class of bug into a red check.

Versioning, releases and codemods

Your system is a dependency. Trust comes from predictable releases.

ChangeVersion bump
Bug fix with no API or visual changePatch
New component, new optional prop, new tokenMinor
Removed or renamed prop or token, changed default, visible restyle of an existing componentMajor

The last row is where teams cheat and regret it. A "small" padding change in a minor release breaks a product team's carefully aligned layout, and they stop upgrading. Changesets makes the bump a reviewed part of each pull request:

npx changeset            # record which packages changed and the bump type
npx changeset version    # in CI: apply bumps and write changelogs
npx changeset publish    # in CI: publish to the registry and tag the release

Every major release ships a codemod. A prop rename is a few lines of jscodeshift:

import type { API, FileInfo } from "jscodeshift";
 
// Renames Button's "kind" prop to "variant" in consuming code.
export default function transform(file: FileInfo, api: API) {
  const j = api.jscodeshift;
  const root = j(file.source);
  root
    .find(j.JSXOpeningElement, { name: { type: "JSXIdentifier", name: "Button" } })
    .forEach((el) => {
      for (const attr of el.node.attributes ?? []) {
        if (attr.type === "JSXAttribute" && attr.name.type === "JSXIdentifier" && attr.name.name === "kind") {
          attr.name.name = "variant";
        }
      }
    });
  return root.toSource();
}

Before removing anything, deprecate it for at least one minor release with a development-only console warning that names the replacement.

What goes wrong in production

  • Version skew. Five apps on five majors, each needing its own fixes. The cause is almost always majors that were too frequent or too painful. Batch breaking changes, ship codemods, and track which version each app runs.
  • Tokens drift from design files. Designers change a color in the design tool and code never hears about it. Pick one source of truth and generate the other from it, or review token changes as code.
  • Two copies of React. A library that lists React as a dependency instead of a peer dependency. Symptoms are "Invalid hook call" errors and contexts that silently do not connect.
  • The directive got stripped. The library works in the playground, which renders everything client-side, and fails in an App Router app. Add a Server Component import test to CI.
  • Specificity wars. Consumers override library styles with !important, then the next release changes a selector and everything breaks. Use a low-specificity convention (single class selectors or cascade layers) so consumer overrides win predictably.
  • Portal stacking. Dialogs, popovers and toasts rendered in portals fight over z-index. Define a z-index scale as tokens and use it everywhere.
  • Forks instead of contributions. A team needs a variant you do not have, cannot wait, and copies your component. Have a published contribution path and a turnaround commitment, or forks are the rational choice.

Adoption: the part that decides success

A design system is a product with internal customers. Treat it like one:

  1. Measure usage. Parse product code for imports of system components versus local look-alikes. Track the ratio per app over time.
  2. Fix fast. A bug in a shared Button blocks many teams at once. Triage system bugs ahead of new components.
  3. Document decisions, not just props. Each component page says when to use it, when not to, and what to use instead.
  4. Make contribution normal. A product team that builds a good composite component should be able to upstream it with a review, not a committee.
  5. Pair design and engineering on every component. The component that exists only in the design file, or only in code, is the beginning of drift.

Frequently Asked Questions

What is the difference between a design system and a component library?

A component library is code: buttons, inputs, dialogs you can import. A design system is the library plus the decisions behind it, meaning design tokens, usage guidelines, accessibility rules, a contribution process and a versioning policy. A library without the system drifts, because nobody owns the decisions.

Should we build our own design system or use an existing library?

Most product teams should build their visual layer on top of headless, accessible primitives such as Radix Primitives or React Aria Components. Writing keyboard handling and focus management for comboboxes, menus and dialogs from scratch is far more work than it looks. Build fully custom only when you have constraints no existing primitive can meet.

What are design tokens?

Design tokens are named values for visual decisions such as colors, spacing, type sizes, radii and motion durations. Good systems layer them: raw palette values, semantic aliases like action or danger, and component-scoped tokens. Themes and dark mode then become a matter of reassigning the semantic layer.

How do you version a design system?

Use semantic versioning and treat every removed prop, renamed token or visible behavior change as a major release. Tools like Changesets record the intended bump with each pull request and automate changelogs and publishing. Ship a codemod with every major so consuming teams can upgrade in minutes rather than days.

How do you get teams to adopt a design system?

Make the system the easiest path: complete components, good docs, fast fixes and a clear way to contribute. Measure adoption by scanning product code for imports of system components versus local one-offs. Mandates without that groundwork produce wrappers and forks, not adoption.

Is shadcn/ui a design system?

shadcn/ui is a collection of components you copy into your own codebase, typically built on Radix primitives and Tailwind CSS. It is a strong starting point for a design system, but once copied the code is yours to maintain, version and document. It gives you components, not the governance that makes them a system.

Need help building a design system?

We build design systems the way we build everything else: for the people who will maintain them after launch. If you need a component library that works in a modern React stack, a token pipeline your designers trust, or help untangling a system that teams have stopped upgrading, talk to the senior engineer who would do the work.