Steven's Knowledge

Design System Tooling

The DevX side of design systems — component development workflows, Storybook, Chromatic, design tokens, publishing pipelines, and adoption metrics that make a component library actually get used

Design System Tooling

A design system is a developer experience product. The Figma files and color palettes are important — but if engineers can't find, install, use, test, and update the components quickly, the system dies. This page covers the tooling and workflow side: how you build, test, document, publish, and measure a component library so that teams actually adopt it instead of writing their own <Button>.

The failure mode isn't bad design — it's bad DevX. A component library with poor docs, no visual tests, and a manual publish process will lose to each team's bespoke components every time.

Why Design Systems Are a DevX Concern

Without system toolingWith system tooling
14 teams, 14 different <Modal> implementationsOne <Modal>, imported and configured
Visual bugs caught in production by usersVisual regressions caught in PR by Chromatic
"Which prop did this component accept?" → grep the sourceStorybook Docs page with live playground
Design token values hard-coded as hex strings across reposTokens generated from a single source, consumed as CSS vars / JS constants
Publishing a component update = manual npm publish from laptopMerge to main → CI builds, tests, publishes, tags
Breaking change in <Table> → 9 repos broken, 3-week migrationCodemod ships with the breaking change; migration takes an afternoon
"Are teams using the system?" → nobody knowsDashboard shows component coverage, token usage, custom CSS ratio

The design system team is a platform team. The component library is an internal product. The tooling below is the infrastructure that makes that product reliable.

Component Development Workflow

The workflow matters as much as the components. Engineers contributing to the design system need:

┌─────────────────────────────────────────────────────┐
│  1. Isolation                                       │
│     Develop components outside the consuming app    │
│     → Storybook / Ladle / Histoire                  │
├─────────────────────────────────────────────────────┤
│  2. Hot reload                                      │
│     Change code → see result in under 200ms          │
│     → Vite-backed Storybook, Ladle                  │
├─────────────────────────────────────────────────────┤
│  3. Documentation                                   │
│     Every component has a page with API, examples,  │
│     do/don't, and a live playground                  │
│     → Storybook Docs, Docusaurus, custom MDX        │
├─────────────────────────────────────────────────────┤
│  4. Testing                                         │
│     Unit, visual regression, interaction, a11y      │
│     → Vitest, Chromatic, Testing Library, axe-core  │
├─────────────────────────────────────────────────────┤
│  5. Review                                          │
│     Designer + engineer see the component in a PR   │
│     → Chromatic, Storybook deploys                  │
├─────────────────────────────────────────────────────┤
│  6. Publish                                         │
│     Merge → versioned package on registry           │
│     → Changesets, semantic-release, npm              │
└─────────────────────────────────────────────────────┘

Tool Comparison: Component Dev Environments

ToolFrameworkSpeedEcosystemBest for
Storybook 8React, Vue, Angular, Svelte, Web ComponentsGood (Vite)Enormous addon ecosystemDefault choice; most mature
LadleReact onlyFast (Vite-native)MinimalSmall React libraries that don't need addons
HistoireVue, SvelteFast (Vite-native)GrowingVue/Svelte-first teams
PlayroomReactFastMinimalDesign-oriented prototyping with themes

Pick Storybook unless you have a specific reason not to. The ecosystem, addon support, and integration surface (Chromatic, a11y, interaction testing) are unmatched.

Storybook Deep Dive

Setup (Storybook 8 + Vite)

# Initialize in an existing project
npx storybook@latest init

# Or with explicit Vite builder
npx storybook@latest init --builder vite

Storybook 8 defaults to Vite. If you're on Webpack, migrate — the speed difference is significant.

Anatomy of a Story

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

const meta: Meta<typeof Button> = {
  component: Button,
  tags: ['autodocs'],           // auto-generates docs page
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'ghost'],
    },
    size: {
      control: 'radio',
      options: ['sm', 'md', 'lg'],
    },
  },
};
export default meta;

type Story = StoryObj<typeof Button>;

// Each export = one story
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Click me',
  },
};

export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Cancel',
  },
};

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

Essential Addons

AddonPurposeInstall
@storybook/addon-essentialsControls, actions, viewport, backgrounds, docsBundled
@storybook/addon-a11yAccessibility checks (axe-core) per storynpm i -D @storybook/addon-a11y
@storybook/addon-interactionsStep-through interaction tests in the panelnpm i -D @storybook/addon-interactions
@storybook/addon-designsEmbed Figma frames alongside storiesnpm i -D @storybook/addon-designs
@storybook/addon-coverageCode coverage for interaction testsnpm i -D @storybook/addon-coverage

Configure in .storybook/main.ts:

// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';

const config: StorybookConfig = {
  stories: ['../src/**/*.stories.@(ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
    '@storybook/addon-a11y',
    '@storybook/addon-interactions',
    '@storybook/addon-designs',
  ],
  framework: '@storybook/react-vite',
};
export default config;

Interaction Testing

Storybook interaction tests run inside the story — the test is the story. This means the test is visible, debuggable, and step-through-able in the browser panel.

// Button.stories.tsx
import { expect, fn, userEvent, within } from '@storybook/test';

export const ClickTest: Story = {
  args: {
    onClick: fn(),       // mock function
    children: 'Submit',
  },
  play: async ({ canvasElement, args }) => {
    const canvas = within(canvasElement);
    const button = canvas.getByRole('button', { name: /submit/i });

    // Verify initial state
    await expect(button).toBeEnabled();

    // Click and verify
    await userEvent.click(button);
    await expect(args.onClick).toHaveBeenCalledOnce();
  },
};

Run in CI with the test runner:

# Run all interaction tests headlessly
npx test-storybook --ci

Visual Testing with Chromatic

Chromatic captures a screenshot of every story on every push, diffs it against the baseline, and blocks the PR if there are unreviewed changes.

# Install
npm i -D chromatic

# Run (typically in CI)
npx chromatic --project-token=<token>

Chromatic in CI (GitHub Actions)

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

jobs:
  chromatic:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0            # Chromatic needs full git history
      - uses: actions/setup-node@v4
        with:
          node-version: 20
      - run: npm ci
      - uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
          exitOnceUploaded: true     # don't block CI; status check handles it

The Review Workflow

Developer pushes PR


CI runs Chromatic ──► screenshots every story


Chromatic diffs against baseline

    ├── No changes ──► ✅ PR check passes

    └── Changes detected ──► 🟡 PR check pending


                          Designer / reviewer opens Chromatic
                          Reviews visual diffs side-by-side

                                ├── Accepts ──► ✅ new baseline, PR check passes
                                └── Denies  ──► ❌ developer fixes

This is the single most impactful tool for a design system. Without it, visual regressions are invisible until someone notices in production.

Component Documentation

Storybook Docs (MDX)

The autodocs tag generates a docs page from your stories and argTypes. For custom docs:

{/* src/components/Button/Button.mdx */}
import { Meta, Story, Canvas, Controls, ArgTypes } from '@storybook/blocks';
import * as ButtonStories from './Button.stories';

<Meta of={ButtonStories} />

# Button

Buttons trigger actions. Use `variant` to set visual weight.

## Do / Don't

- ✅ Use `primary` for the main action on a page
- ✅ Use `ghost` for tertiary actions
- ❌ Don't put two `primary` buttons side by side
- ❌ Don't use a button when a link is semantically correct

## Playground

<Canvas of={ButtonStories.Primary} />
<Controls of={ButtonStories.Primary} />

## All Variants

<ArgTypes of={ButtonStories} />

Docusaurus for Full Documentation Sites

For systems large enough to need guides, migration docs, and design principles beyond component API:

docs/
├── getting-started.mdx
├── design-principles.mdx
├── components/
│   ├── button.mdx          ← embeds live Storybook via iframe
│   ├── modal.mdx
│   └── table.mdx
├── tokens/
│   ├── color.mdx
│   └── spacing.mdx
└── migration/
    ├── v2-to-v3.mdx
    └── codemods.mdx

Embed Storybook stories inside Docusaurus pages:

## Live Example

<iframe
  src="https://your-storybook.chromatic.com/?path=/story/button--primary&viewMode=story"
  width="100%"
  height="200"
  style={{ border: 'none' }}
/>

Design Tokens

Design tokens are the atomic values of a design system: colors, spacing, typography, shadows, border radii. They bridge design and code — a single source of truth consumed by CSS, JS, iOS, Android, and Flutter.

What Tokens Look Like

┌──────────────────────────────────────────────────────┐
│  Source (JSON / Figma / Tokens Studio)                │
│                                                      │
│  color.primary.500 = #2563eb                         │
│  spacing.md        = 16                              │
│  font.body.size    = 16                              │
│  shadow.card       = 0 1px 3px rgba(0,0,0,0.12)     │
└──────────────┬───────────────────────────────────────┘
               │  Style Dictionary / token pipeline

┌──────────────────────────┐  ┌──────────────────────────┐
│  CSS custom properties   │  │  JS/TS constants          │
│  --color-primary-500     │  │  COLOR_PRIMARY_500        │
│  --spacing-md            │  │  SPACING_MD               │
└──────────────────────────┘  └──────────────────────────┘
┌──────────────────────────┐  ┌──────────────────────────┐
│  iOS (Swift)             │  │  Android (XML/Compose)    │
│  Color.primary500        │  │  colorPrimary500          │
└──────────────────────────┘  └──────────────────────────┘

Token Pipeline with Style Dictionary

// tokens/color.json (W3C Design Token format)
{
  "color": {
    "primary": {
      "500": { "$value": "#2563eb", "$type": "color" },
      "600": { "$value": "#1d4ed8", "$type": "color" },
      "700": { "$value": "#1e40af", "$type": "color" }
    },
    "neutral": {
      "100": { "$value": "#f5f5f5", "$type": "color" },
      "900": { "$value": "#171717", "$type": "color" }
    }
  }
}
// style-dictionary.config.mjs
import StyleDictionary from 'style-dictionary';

export default {
  source: ['tokens/**/*.json'],
  platforms: {
    css: {
      transformGroup: 'css',
      buildPath: 'dist/css/',
      files: [{
        destination: 'tokens.css',
        format: 'css/variables',
      }],
    },
    js: {
      transformGroup: 'js',
      buildPath: 'dist/js/',
      files: [{
        destination: 'tokens.mjs',
        format: 'javascript/es6',
      }],
    },
    ios: {
      transformGroup: 'ios-swift',
      buildPath: 'dist/ios/',
      files: [{
        destination: 'Tokens.swift',
        format: 'ios-swift/class.swift',
        className: 'DesignTokens',
      }],
    },
  },
};
# Generate all platform outputs
npx style-dictionary build

Token Tool Comparison

ToolStrengthBest for
Style Dictionary (Amazon)Extensible transforms, multi-platform output, wide adoptionDefault choice for token pipelines
Tokens Studio (Figma plugin)Direct Figma ↔ JSON sync; designers edit tokens in FigmaTeams where designers manage tokens
Cobalt UIW3C Design Token spec-first, fast CLITeams committed to the W3C spec
Theo (Salesforce)Predecessor to Style Dictionary, simplerLegacy; migrate to Style Dictionary

Token Pipeline in CI

# .github/workflows/tokens.yml
name: Build Tokens
on:
  push:
    paths: ['tokens/**']

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx style-dictionary build
      - uses: actions/upload-artifact@v4
        with:
          name: design-tokens
          path: dist/

When tokens change → CI builds all platform outputs → downstream packages consume the artifacts.

Component Publishing and Distribution

Package Structure

A typical design system monorepo:

design-system/
├── packages/
│   ├── tokens/              ← design tokens (published as @acme/tokens)
│   │   ├── tokens/
│   │   ├── style-dictionary.config.mjs
│   │   └── package.json
│   ├── react/               ← React components (published as @acme/ui)
│   │   ├── src/
│   │   ├── package.json
│   │   └── tsconfig.json
│   ├── vue/                 ← Vue components (published as @acme/ui-vue)
│   │   └── ...
│   └── icons/               ← Icon package (published as @acme/icons)
│       └── ...
├── apps/
│   └── storybook/           ← Storybook app (not published)
├── package.json
├── turbo.json               ← or nx.json
└── .changeset/
    └── config.json

Versioning with Changesets

Changesets is the standard for versioning monorepo packages. Each PR that changes a package includes a changeset file describing what changed and the semver bump.

# Developer adds a changeset during PR
npx changeset add

# Prompts:
# Which packages? → @acme/ui
# Bump type? → minor
# Summary? → Add size prop to Avatar component

This creates a markdown file in .changeset/:

---
'@acme/ui': minor
---

Add `size` prop to Avatar component. Accepts `sm`, `md`, `lg`.

On merge to main, a GitHub Action consumes changesets and opens a "Version Packages" PR:

# .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
      - name: Create Release PR or Publish
        uses: changesets/action@v1
        with:
          publish: npm run release    # runs changeset publish
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

Versioning Strategy

StrategyWhen to use
Independent versions per packageLarge system with packages that evolve at different speeds
Fixed/locked versions (all packages same version)Small system; simpler for consumers to reason about compatibility
Calendar versioning (2025.01)Rarely appropriate for component libraries; more common for platforms

Independent versions + Changesets is the default. It lets the token package ship v3.2 while the React package is at v5.1 without confusion — as long as peer dependency ranges are maintained.

Migration Tooling

Breaking changes in a component library affect every consuming repo. Shipping a codemod alongside the breaking change is the difference between a one-afternoon migration and a three-week migration.

jscodeshift Codemods

// codemods/rename-variant-prop.js
// Transforms: <Button type="primary" /> → <Button variant="primary" />

export default function transformer(file, api) {
  const j = api.jscodeshift;
  const root = j(file.source);

  root
    .find(j.JSXOpeningElement, { name: { name: 'Button' } })
    .find(j.JSXAttribute, { name: { name: 'type' } })
    .forEach((path) => {
      path.node.name.name = 'variant';
    });

  return root.toSource();
}
# Run across a consuming repo
npx jscodeshift -t codemods/rename-variant-prop.js src/ --extensions=tsx,ts

Ship Codemods with the Package

packages/react/
├── src/
├── codemods/
│   ├── v5-to-v6/
│   │   ├── rename-variant-prop.js
│   │   ├── remove-deprecated-icons.js
│   │   └── __tests__/
│   └── v4-to-v5/
│       └── ...
└── package.json

Document them in the migration guide and run them in CI against a test fixture to make sure the codemod itself doesn't break.

Testing Strategy

A component library needs layered testing. The ratio is different from application testing — visual correctness and accessibility matter more; end-to-end flows don't exist.

┌─────────────────────────────────────────────┐
│  Visual regression (Chromatic / Percy)       │  ← catches pixel-level regressions
│  Run per PR; screenshots every story         │
├─────────────────────────────────────────────┤
│  Interaction tests (Storybook play functions)│  ← tests behavior in real browser
│  Click, type, assert — visible in Storybook  │
├─────────────────────────────────────────────┤
│  Accessibility (axe-core + Storybook a11y)   │  ← WCAG violations caught automatically
│  Run per story; fail CI on violations        │
├─────────────────────────────────────────────┤
│  Unit tests (Vitest + Testing Library)       │  ← logic, state, edge cases
│  Fast; run on every change                   │
└─────────────────────────────────────────────┘

Unit Tests

// Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from './Button';

test('calls onClick when clicked', async () => {
  const onClick = vi.fn();
  render(<Button onClick={onClick}>Submit</Button>);

  await userEvent.click(screen.getByRole('button', { name: /submit/i }));
  expect(onClick).toHaveBeenCalledOnce();
});

test('renders as disabled when isLoading', () => {
  render(<Button isLoading>Save</Button>);
  expect(screen.getByRole('button')).toBeDisabled();
});

Accessibility Automation

axe-core in Unit Tests

import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

test('Button has no a11y violations', async () => {
  const { container } = render(<Button>Click</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Storybook a11y Addon

The @storybook/addon-a11y addon runs axe-core on every story and displays violations in the panel. To fail CI on violations:

// .storybook/test-runner.ts
import { checkA11y, injectAxe } from 'axe-playwright';

export default {
  async preVisit(page) {
    await injectAxe(page);
  },
  async postVisit(page) {
    await checkA11y(page, '#storybook-root', {
      detailedReport: true,
      detailedReportOptions: { html: true },
    });
  },
};
npx test-storybook --ci   # now fails if any story has a11y violations

Adoption Metrics

Building the system is 30% of the work. Getting teams to use it — and keep using it — is 70%. You need data.

What to Measure

MetricHow to measureTarget
Component coverageAST scan: % of UI elements that are design system components vs custom>80% in mature repos
Token usageLint rule: % of color/spacing values that reference tokens vs hard-coded>90%
Custom CSS ratioLines of custom CSS per feature vs lines from the systemTrending down
Import frequencynpm download stats or bundler import analysis per componentIdentifies most/least used components
Time to first componentHow long from npm install to rendering the first componentUnder 10 min
PR adoption rate% of UI PRs that import from the design system>70%

Automated Scanning

A custom ESLint rule can enforce token usage:

// eslint-plugin-design-system/rules/no-hardcoded-colors.js
module.exports = {
  meta: {
    type: 'suggestion',
    messages: {
      hardcodedColor: 'Use a design token instead of hard-coded color "{{value}}".',
    },
  },
  create(context) {
    return {
      Property(node) {
        if (
          ['color', 'backgroundColor', 'borderColor'].includes(node.key.name) &&
          node.value.type === 'Literal' &&
          /^#[0-9a-f]{3,8}$/i.test(node.value.value)
        ) {
          context.report({
            node,
            messageId: 'hardcodedColor',
            data: { value: node.value.value },
          });
        }
      },
    };
  },
};

Multi-Framework Support

Large organizations run React, Vue, Angular, and sometimes Web Components. The design system has to meet them where they are.

Strategy Comparison

ApproachEffortConsistencyBest for
Web Components as base → thin wrappers for React/VueHigh initial, low ongoingVery highOrgs with 3+ frameworks
React primary → separate Vue/Angular portsMediumMedium (ports drift)React-dominant orgs
Headless/unstyled primitives (Radix, Ark UI) + shared tokensLow per frameworkToken-level consistencyOrgs that accept different component impls
One framework onlyLowPerfect (for that framework)Single-framework orgs

Web Components + Wrappers

// Base Web Component
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('ds-button')
export class DsButton extends LitElement {
  @property() variant: 'primary' | 'secondary' = 'primary';
  @property({ type: Boolean }) disabled = false;

  static styles = css`
    :host { display: inline-block; }
    button { /* uses design tokens via CSS custom properties */ }
  `;

  render() {
    return html`
      <button class=${this.variant} ?disabled=${this.disabled}>
        <slot></slot>
      </button>
    `;
  }
}
// React wrapper (auto-generated or manual)
import { createComponent } from '@lit/react';
import { DsButton as DsButtonWC } from '@acme/web-components';

export const Button = createComponent({
  tagName: 'ds-button',
  elementClass: DsButtonWC,
  react: React,
  events: { onClick: 'click' },
});

Integration with CI/CD

A mature design system CI pipeline:

# .github/workflows/ci.yml
name: Design System CI
on: pull_request

jobs:
  lint-and-test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npm run lint
      - run: npm run test              # unit tests (Vitest)
      - run: npm run test:storybook    # interaction tests

  visual-review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - run: npm ci
      - uses: chromaui/action@latest
        with:
          projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}

  a11y:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx storybook build --quiet
      - run: |
          npx concurrently -k -s first -n "SB,TEST" \
            "npx http-server storybook-static -p 6006 --silent" \
            "npx wait-on tcp:6006 && npx test-storybook --ci"

  tokens:
    runs-on: ubuntu-latest
    if: contains(github.event.pull_request.changed_files, 'tokens/')
    steps:
      - uses: actions/checkout@v4
      - run: npm ci
      - run: npx style-dictionary build
      - run: npm run test:tokens       # validate generated output

PR checks summary:

✅  Lint & Test         — passed
✅  Chromatic           — 3 visual changes accepted by reviewer
✅  Accessibility       — 0 violations
✅  Tokens Build        — skipped (no token changes)

Anti-Patterns

"The Figma-to-code fantasy." Believing a tool will auto-generate production components from Figma. The generated code is a starting point at best. The real engineering — state management, accessibility, edge cases, performance — can't be drawn.

"Ship the kitchen sink." Publishing 80 components on day one. Nobody uses 60 of them, but the team spends 40% of its time maintaining them. Start with 10 components that cover 80% of the UI; add the rest when teams request them.

"Version 1.0 forever." Never making a breaking change because migrations are painful. This leads to accumulating deprecated props, confusing APIs, and eventual abandonment. Ship breaking changes with codemods and migration guides on a predictable cadence (once or twice a year).

"Docs are optional." An undocumented component is an unused component. Engineers won't read your source code to figure out the API. If it doesn't have a Storybook page with examples, it doesn't exist.

"Testing only in the consumer." Relying on downstream apps to catch design system bugs. By the time the consumer's tests fail, the damage is done and 12 teams are blocked. Test exhaustively in the design system itself.

"Tokens in a spreadsheet." Managing token values in a Google Sheet or Confluence page that someone manually copies into code. One desync and the system's credibility is gone. Tokens must be code, in version control, built by CI.

"No adoption data." Building a system for two years without ever measuring whether anyone uses it. When leadership finally asks "what's the ROI?", the answer is a shrug. Instrument adoption from day one.

The design system is a product, not a project. Projects end; products iterate. The tooling described on this page — Storybook for development, Chromatic for visual review, Changesets for publishing, codemods for migration, metrics for adoption — is the infrastructure that turns a folder of components into a product engineers trust. The moment the system stops evolving faster than the teams' bespoke alternatives, it loses. Invest in the DevX or accept that teams will route around you.

On this page