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 tooling | With system tooling |
|---|---|
14 teams, 14 different <Modal> implementations | One <Modal>, imported and configured |
| Visual bugs caught in production by users | Visual regressions caught in PR by Chromatic |
| "Which prop did this component accept?" → grep the source | Storybook Docs page with live playground |
| Design token values hard-coded as hex strings across repos | Tokens generated from a single source, consumed as CSS vars / JS constants |
| Publishing a component update = manual npm publish from laptop | Merge to main → CI builds, tests, publishes, tags |
Breaking change in <Table> → 9 repos broken, 3-week migration | Codemod ships with the breaking change; migration takes an afternoon |
| "Are teams using the system?" → nobody knows | Dashboard 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
| Tool | Framework | Speed | Ecosystem | Best for |
|---|---|---|---|---|
| Storybook 8 | React, Vue, Angular, Svelte, Web Components | Good (Vite) | Enormous addon ecosystem | Default choice; most mature |
| Ladle | React only | Fast (Vite-native) | Minimal | Small React libraries that don't need addons |
| Histoire | Vue, Svelte | Fast (Vite-native) | Growing | Vue/Svelte-first teams |
| Playroom | React | Fast | Minimal | Design-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 viteStorybook 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
| Addon | Purpose | Install |
|---|---|---|
@storybook/addon-essentials | Controls, actions, viewport, backgrounds, docs | Bundled |
@storybook/addon-a11y | Accessibility checks (axe-core) per story | npm i -D @storybook/addon-a11y |
@storybook/addon-interactions | Step-through interaction tests in the panel | npm i -D @storybook/addon-interactions |
@storybook/addon-designs | Embed Figma frames alongside stories | npm i -D @storybook/addon-designs |
@storybook/addon-coverage | Code coverage for interaction tests | npm 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 --ciVisual 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 itThe 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 fixesThis 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.mdxEmbed 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 buildToken Tool Comparison
| Tool | Strength | Best for |
|---|---|---|
| Style Dictionary (Amazon) | Extensible transforms, multi-platform output, wide adoption | Default choice for token pipelines |
| Tokens Studio (Figma plugin) | Direct Figma ↔ JSON sync; designers edit tokens in Figma | Teams where designers manage tokens |
| Cobalt UI | W3C Design Token spec-first, fast CLI | Teams committed to the W3C spec |
| Theo (Salesforce) | Predecessor to Style Dictionary, simpler | Legacy; 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.jsonVersioning 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 componentThis 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
| Strategy | When to use |
|---|---|
| Independent versions per package | Large 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,tsShip Codemods with the Package
packages/react/
├── src/
├── codemods/
│ ├── v5-to-v6/
│ │ ├── rename-variant-prop.js
│ │ ├── remove-deprecated-icons.js
│ │ └── __tests__/
│ └── v4-to-v5/
│ └── ...
└── package.jsonDocument 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 violationsAdoption 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
| Metric | How to measure | Target |
|---|---|---|
| Component coverage | AST scan: % of UI elements that are design system components vs custom | >80% in mature repos |
| Token usage | Lint rule: % of color/spacing values that reference tokens vs hard-coded | >90% |
| Custom CSS ratio | Lines of custom CSS per feature vs lines from the system | Trending down |
| Import frequency | npm download stats or bundler import analysis per component | Identifies most/least used components |
| Time to first component | How long from npm install to rendering the first component | Under 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
| Approach | Effort | Consistency | Best for |
|---|---|---|---|
| Web Components as base → thin wrappers for React/Vue | High initial, low ongoing | Very high | Orgs with 3+ frameworks |
| React primary → separate Vue/Angular ports | Medium | Medium (ports drift) | React-dominant orgs |
| Headless/unstyled primitives (Radix, Ark UI) + shared tokens | Low per framework | Token-level consistency | Orgs that accept different component impls |
| One framework only | Low | Perfect (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 outputPR 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.