Design systems fail when they become museums — documented but unused. After building design systems for five enterprise-scale Next.js applications at WEIRDSOFT, we have learned the hard way what separates a living system from a frozen artifact. A design system is not a UI component library. It is a governance model, a communication protocol between design and engineering, and a leverage point that compounds every time a new product screen is built.
This post covers the full lifecycle: auditing what you already have, defining a token taxonomy, designing component APIs with CVA, documenting for real humans, versioning intelligently, automating quality with CI/CD, governing contributions across teams, measuring adoption, and baking accessibility in at the foundation level.
Auditing Existing Patterns Before You Build
The single most common mistake teams make is designing a design system from first principles in a vacuum. They sketch buttons, invent color palettes, and produce a Figma library that maps to nothing in production. Six months later, the library sits abandoned while teams continue building with their own bespoke patterns.
Start with an audit instead.
Pattern Inventory
Run a systematic audit of your production codebase. For each page or feature module, catalog every recurring UI pattern you find:
| Pattern | Variants | Frequency | Consistency |
|---|---|---|---|
| Button | 14 variants across 3 codebases | 200+ instances | Low — 5 different hover styles |
| Card | 8 structural patterns | 90 instances | Medium — shared wrapper, varied internals |
| Modal | 3 implementations (headless UI, custom, MUI) | 30 instances | Low — different close behaviors |
| Form Input | 6 wrapper approaches | 150 instances | Very low — validation UX differs |
This data tells you where to start. Button variants are proliferating because teams lack a flexible enough API. Cards are reasonably consistent, so they can be lower priority. Modals need behavioral standardization more than visual polish.
Use a combination of static analysis (searching for JSX patterns with AST parsers) and manual review. Tools like ts-morph can traverse your TypeScript AST and extract component usage statistics automatically:
import { Project } from 'ts-morph';
const project = new Project({
tsConfigFilePath: './tsconfig.json',
});
const buttonUsages: Array<{ file: string; props: Record<string, string> }> = [];
const sourceFiles = project.getSourceFiles();
for (const file of sourceFiles) {
const jsxElements = file.getDescendantsOfKind(
ts.SyntaxKind.JsxSelfClosingElement
);
for (const el of jsxElements) {
if (el.getTagNameNode().getText() === 'Button' ||
el.getTagNameNode().getText() === 'button') {
// Collect props to understand variant usage
const props: Record<string, string> = {};
el.getAttributes().forEach((attr) => {
if (ts.SyntaxKind[attr.getKind()].includes('JsxAttribute')) {
const name = attr.getText().split('=')[0];
props[name] = attr.getText();
}
});
buttonUsages.push({ file: file.getFilePath(), props });
}
}
}
With this data you can justify every design decision with real production evidence. "We need an icon button variant because seventeen places in the app already render one with hand-written styles."
Visual Regression Baseline
Before you change anything, establish a visual baseline. Use tools like Percy, Chromatic, or Playwright Visual Comparison to capture screenshots of every existing component. This gives you a safety net when you start refactoring toward the new system.
Token Taxonomy That Scales
Design tokens are the atoms of your design system. Get them right and everything else composes naturally. Get them wrong and you will be fighting specificity wars with !important within a month.
Three-Tier Token Model
We use a three-tier taxonomy inspired by the System UI Theme Specification but adapted for real-world Next.js applications:
Global tokens are the raw material. They map directly to immutable values and never carry semantic meaning:
{
"color": {
"blue": {
"50": "#eff6ff",
"100": "#dbeafe",
"200": "#bfdbfe",
"500": "#3b82f6",
"600": "#2563eb",
"700": "#1d4ed8"
},
"gray": {
"50": "#f9fafb",
"100": "#f3f4f6",
"800": "#1f2937",
"900": "#111827"
}
},
"typography": {
"fontFamily": {
"sans": "Inter, system-ui, -apple-system, sans-serif",
"mono": "JetBrains Mono, Fira Code, monospace"
},
"fontSize": {
"xs": "0.75rem",
"sm": "0.875rem",
"base": "1rem",
"lg": "1.125rem",
"xl": "1.25rem",
"2xl": "1.5rem",
"3xl": "1.875rem"
},
"fontWeight": {
"normal": "400",
"medium": "500",
"semibold": "600",
"bold": "700"
}
},
"spacing": {
"0": "0px",
"1": "0.25rem",
"2": "0.5rem",
"3": "0.75rem",
"4": "1rem",
"5": "1.25rem",
"6": "1.5rem",
"8": "2rem",
"10": "2.5rem",
"12": "3rem"
},
"borderRadius": {
"none": "0px",
"sm": "0.125rem",
"md": "0.375rem",
"lg": "0.5rem",
"xl": "0.75rem",
"full": "9999px"
}
}
Semantic tokens alias global tokens to functional roles. This is where theming happens — you swap the alias, not the value:
{
"color": {
"brand": {
"primary": "{color.blue.500}",
"primaryHover": "{color.blue.600}",
"primaryOnDark": "{color.blue.200}"
},
"surface": {
"page": "{color.gray.50}",
"card": "{color.gray.50}",
"elevated": "#ffffff"
},
"text": {
"primary": "{color.gray.900}",
"secondary": "{color.gray.500}",
"inverse": "#ffffff",
"link": "{color.blue.600}"
},
"border": {
"default": "{color.gray.200}",
"focus": "{color.blue.500}"
}
}
}
Component tokens are scoped to a specific component. These are the most specific tier and should be used sparingly:
{
"Button": {
"paddingX": "{spacing.4}",
"paddingY": "{spacing.2}",
"fontSize": "{typography.fontSize.sm}",
"borderRadius": "{borderRadius.md}"
}
}
Output Formats
Store your tokens as platform-agnostic JSON, then generate platform-specific outputs during your build step:
// tokens/tailwind.ts — Extend Tailwind with design tokens
export const tailwindTokenExtensions = {
extend: {
colors: {
brand: {
primary: 'var(--color-brand-primary)',
hover: 'var(--color-brand-primary-hover)',
},
surface: {
page: 'var(--color-surface-page)',
card: 'var(--color-surface-card)',
},
},
spacing: mapTokens(spacing, 'spacing'),
borderRadius: mapTokens(borderRadius, 'radius'),
},
};
/* tokens/variables.css — CSS custom properties for runtime */
:root {
--color-brand-primary: #3b82f6;
--color-brand-primary-hover: #2563eb;
--color-surface-page: #f9fafb;
--color-surface-card: #f9fafb;
--color-text-primary: #111827;
}
This approach means your tokens are the single source of truth. Tailwind classes, inline styles, and CSS modules all reference the same canonical values.
Component API Design with CVA
Component API design is where most design systems go to die. The tension is between flexibility (developers want to customize everything) and consistency (designers want everything to look the same). Class Variance Authority (CVA) gives you a structured way to resolve this.
Why CVA
CVA provides a type-safe, composable way to define component variants. Unlike Tailwind's clsx-based conditional classes, CVA gives you a formal variant specification that is both human-readable and machine-verifiable:
import { cva, type VariantProps } from 'class-variance-authority';
export const buttonVariants = cva(
// Base styles applied to every variant
[
'inline-flex items-center justify-center rounded-lg font-medium',
'transition-colors duration-200 focus-visible:outline-none',
'focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
],
{
variants: {
variant: {
primary: [
'bg-brand-primary text-white',
'hover:bg-brand-primary-hover',
'focus-visible:ring-brand-primary',
],
secondary: [
'bg-surface-card text-text-primary border border-border-default',
'hover:bg-gray-100',
'focus-visible:ring-brand-primary',
],
ghost: [
'text-text-primary hover:bg-gray-100',
'focus-visible:ring-brand-primary',
],
danger: [
'bg-red-600 text-white',
'hover:bg-red-700',
'focus-visible:ring-red-600',
],
},
size: {
sm: 'h-9 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-11 px-6 text-base',
xl: 'h-14 px-8 text-lg',
},
fullWidth: {
true: 'w-full',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
},
}
);
The resulting component API is explicit and self-documenting:
import { forwardRef, type ButtonHTMLAttributes } from 'react';
import { type VariantProps } from 'class-variance-authority';
import { buttonVariants } from './variants';
import { Slot } from '@radix-ui/react-slot';
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, fullWidth, asChild, isLoading, children, disabled, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
className={buttonVariants({ variant, size, fullWidth, className })}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? (
<>
<Spinner className="mr-2 h-4 w-4 animate-spin" aria-hidden />
<span className="sr-only">Loading</span>
</>
) : null}
{children}
</Comp>
);
}
);
Button.displayName = 'Button';
The asChild pattern (borrowed from Radix UI's Slot) is critical. It lets consumers render the button's styles on a different element — an <a> tag for navigation, a <div> as a drop target — while preserving design system constraints.
Compound Variants and Composition
Real UIs need combinations that interact. An danger variant with large size needs different hover ring offsets. CVA handles this elegantly:
export const buttonVariants = cva(baseStyles, {
variants: { /* ... */ },
compoundVariants: [
{
variant: 'danger',
size: 'xl',
className: 'ring-offset-2 ring-red-600',
},
{
variant: 'primary',
fullWidth: true,
className: 'justify-center',
},
],
defaultVariants: { variant: 'primary', size: 'md' },
});
Polymorphic Component Recipes
For complex components like Select, Dialog, or Tabs, we use CVA at the primitive level and compose with Radix UI or React Aria components. The pattern is the same: variant definitions stay in a single file, the component renders the primitive, and consumers get a constrained but flexible API.
Documentation Strategy That Ships
If developers cannot figure out how to use your component in thirty seconds, they will build their own. Documentation must be co-located with code, interactive, and searchable.
Storybook as a Live Spec
We treat Storybook as the canonical reference implementation, not an afterthought. Every component ships with:
- Interactive stories that exercise every variant, state, and edge case
- Controls that let developers tweak props in real time
- Accessibility panel showing ARIA violations automatically
- Automatic prop tables generated from TypeScript interfaces
- Design embed from Figma showing the intended visual spec
// Button.stories.ts
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Primitives/Button',
component: Button,
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'ghost', 'danger'],
},
size: { control: 'select', options: ['sm', 'md', 'lg', 'xl'] },
isLoading: { control: 'boolean' },
disabled: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: { variant: 'primary', children: 'Submit' },
};
export const WithLoading: Story = {
args: { variant: 'primary', children: 'Saving...', isLoading: true },
};
export const Danger: Story = {
args: { variant: 'danger', children: 'Delete Account', size: 'lg' },
};
Usage Guidelines Over API Reference
Documenting props is table stakes. The real value is in usage guidelines: when to use this component, when to use something else, accessibility considerations, and code patterns for common scenarios.
We use a structured documentation template for every component:
## When to use
[One paragraph about the intended use case]
## When to consider something else
[Alternatives — e.g., "Use Link instead of Button variant="link" for navigation"]
## Accessibility
- Keyboard interaction requirements
- ARIA roles and attributes
- Color contrast ratios
- Screen reader behavior
## Examples
- Basic usage
- With loading state
- Within a form
- Custom styled (rare — only when justified)
## Related components
- Link to related primitives
- Link to composite patterns
Versioning and Release Strategy
Semantic versioning for design systems is trickier than application code. A breaking change might be visual only — no API changed, but the button is now 2px taller and that breaks a layout.
Breaking Change Taxonomy
| Change Type | Semver Bump | Example |
|---|---|---|
| New component added | Minor | Adding Breadcrumb component |
| New variant added | Minor | Adding variant="success" to Button |
| Token value changed (visual) | Major | Primary brand color changes |
| Component API changed | Major | Renaming variant prop to appearance |
| Deprecated variant removed | Major | Removing variant="plain" |
| Bug fix (no visual change) | Patch | Fixing ARIA attribute |
Changesets for Automated Release
We use @changesets/cli to manage versioning. Every PR that modifies a component must include a changeset file describing the change:
# Install changesets
pnpm add -D @changesets/cli @changesets/changelog-github
# Initialize
pnpm changeset init
# Create a changeset
pnpm changeset
The changeset prompts for the type of change (major/minor/patch) and a description. On release, changesets aggregate into a changelog and bump versions automatically:
# @weirdsoft/design-system
## 2.1.0
### Minor Changes
- **Button**: Added `variant="success"` for confirmation actions
- **Dialog**: Added `size` prop (`sm` | `md` | `lg`) for dialog width control
### Patch Changes
- **Button**: Fixed focus-visible ring clipping on Safari
- **Tokens**: Adjusted `color.brand.primary` luminance for better contrast
CI/CD for Design Systems
Your design system needs its own CI pipeline, separate from application CI. Every pull request against the design system repository should run:
Pipeline Stages
# .github/workflows/design-system-ci.yml
name: Design System CI
on:
pull_request:
paths:
- 'packages/design-system/**'
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v2
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'pnpm'
- run: pnpm install --frozen-lockfile
# Static checks
- run: pnpm tsc --noEmit
- run: pnpm lint
- run: pnpm format:check
# Unit and integration tests
- run: pnpm test -- --coverage
# Build to catch compilation errors
- run: pnpm build
# Visual regression testing
- uses: chromaui/action@v1
with:
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
token: ${{ secrets.GITHUB_TOKEN }}
# Accessibility testing
- run: pnpm test:a11y
Visual Regression Testing
Visual regression is the most important CI stage for a design system. We use Chromatic because it integrates directly with Storybook and provides a review UI for designers:
# Run Chromatic on your Storybook
npx chromatic --project-token=<token> --exit-once-uploaded
Every PR shows designers a side-by-side diff of visual changes. Designers approve or reject changes directly in the Chromatic UI, and the status is reported back to the GitHub PR. This creates a clear handoff: "the engineer made changes, the designer reviewed the visuals, and here is the approved spec."
Automated Accessibility Auditing
Run axe-core against every Storybook story as part of CI:
// .storybook/test-runner.ts
import { checkA11y, injectAxe } from 'axe-playwright';
import type { TestRunnerConfig } from '@storybook/test-runner';
const config: TestRunnerConfig = {
async postVisit(page, context) {
await injectAxe(page);
await checkA11y(page, '#storybook-root', {
detailedReport: true,
detailedReportOptions: {
html: true,
},
});
},
};
export default config;
This catches contrast ratio violations, missing ARIA labels, and focus order issues before they ever reach production.
Adoption Rollout Strategy
The best design system in the world is worthless if no one uses it. Adoption is a change management problem, not a technical one.
The Strangler Fig Pattern
Do not attempt a rewrite. Use the strangler fig pattern: wrap the old component, offer the new one alongside it, and gradually deprecate the old one.
// Deprecated — use NewButton instead
/** @deprecated Use `NewButton` from `@weirdsoft/ui` */
export const OldButton = (props: OldButtonProps) => { /* ... */ };
// New component — feature-complete
export { Button as NewButton } from '@weirdsoft/design-system';
Run a codemod to identify all usages and surface an automated migration path:
// codemods/button-v1-to-v2.ts
export default function transformer(file, api) {
const j = api.jscodeshift;
const root = j(file.source);
return root
.find(j.ImportDeclaration, {
source: { value: (v) => v.includes('old-button') },
})
.replaceWith((path) => {
const specifiers = path.node.specifiers.map((spec) => {
if (spec.imported.name === 'Button') {
return j.importSpecifier(
j.identifier('Button'),
j.identifier('Button')
);
}
return spec;
});
return j.importDeclaration(specifiers, j.literal('@weirdsoft/ui'));
})
.toSource();
}
Adoption Tiers
Roll out in waves, monitoring each tier before proceeding:
| Tier | Teams | Goal | Timeline |
|---|---|---|---|
| 1 | Design system team + 1 partner team | Dogfooding, catch API issues | Weeks 1-4 |
| 2 | 2-3 product teams | Validate in real workflows | Weeks 5-8 |
| 3 | Remaining teams + external consumers | Full rollout | Weeks 9-12 |
Measuring Adoption
You cannot improve what you do not measure. Track these metrics:
- Component usage count: How many instances across the codebase? Tracked via static analysis.
- Adoption rate: Percentage of total component instances that come from the design system vs. hand-rolled alternatives.
- Deprecated import ratio: How many old imports still exist?
- Time-to-integration: How long does it take a new team to adopt their first component?
- Issue-to-resolution: How fast are design system bugs fixed?
// scripts/measure-adoption.ts
import { Project } from 'ts-morph';
async function measureAdoption() {
const project = new Project({ tsConfigFilePath: './tsconfig.json' });
const designSystemImports = new Set<string>();
const legacyImports = new Set<string>();
for (const file of project.getSourceFiles()) {
const imports = file.getImportDeclarations();
for (const imp of imports) {
const source = imp.getModuleSpecifierValue();
if (source.includes('@weirdsoft/design-system')) {
imp.getNamedImports().forEach((n) =>
designSystemImports.add(n.getName())
);
}
if (source.includes('@weirdsoft/legacy')) {
imp.getNamedImports().forEach((n) =>
legacyImports.add(n.getName())
);
}
}
}
const total = designSystemImports.size + legacyImports.size;
const adoptionRate = total > 0
? (designSystemImports.size / total) * 100
: 0;
console.log(`Adoption rate: ${adoptionRate.toFixed(1)}%`);
console.log(`DS components in use: ${designSystemImports.size}`);
console.log(`Legacy components remaining: ${legacyImports.size}`);
}
Cross-Team Governance
Governance is the most overlooked aspect of design systems. Without clear ownership, the system drifts: one team adds a prop, another overrides styles, and before long the design system is a "recommendation" rather than a source of truth.
The Design System Council
Form a cross-functional council with representatives from each major product team. The council meets bi-weekly and owns:
- RFC process: Proposals for new components or API changes go through a lightweight RFC. The template asks: what is the use case, what alternatives were considered, and which teams will use this?
- Breaking change approval: No breaking change ships without council consensus and a documented migration path.
- Backlog prioritization: Teams submit requests as GitHub issues; the council prioritizes based on impact across teams.
Contribution Model
Accept contributions from product teams, but enforce standards:
- Proposal: File an RFC with component API sketch and design mock
- Review: Council reviews for scope, overlap, and accessibility
- Implementation: Contributor builds the component following system conventions (CVA, tokens, tests, stories)
- Review: Design system team reviews for code quality and consistency
- Release: Changeset added, merged, released with appropriate version bump
This model scales because the design system team becomes an enabler rather than a bottleneck. They maintain the architecture and conventions; product teams contribute the domain-specific components.
Accessibility Enforcement at the Foundation
Accessibility cannot be retrofitted. Every component in your design system must be accessible by default — not "accessible if the consumer remembers to pass the right ARIA props."
Automated Enforcement Layers
We use a layered approach:
- Design token level: All color tokens must pass WCAG 2.1 AA contrast ratios against their intended background tokens. This is verified in CI with a script that checks every token pair.
- Component level: Each component gets an accessibility test that runs axe-core against every variant and state:
// Button.a11y.test.tsx
import { render } from '@testing-library/react';
import { axe } from 'jest-axe';
import { Button } from './Button';
describe('Button accessibility', () => {
it('should have no violations for primary variant', async () => {
const { container } = render(<Button>Submit</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no violations for danger variant', async () => {
const { container } = render(<Button variant="danger">Delete</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it('should have no violations when disabled', async () => {
const { container } = render(<Button disabled>Disabled</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
- Integration level: Automated E2E tests run axe-core on every page of the application to catch composition-level issues — a heading order problem caused by nesting components, for instance.
Keyboard Interaction Contracts
Every interactive component ships with a defined keyboard interaction contract documented in Storybook:
| Component | Key | Action |
|---|---|---|
| Button | Enter/Space | Activates |
| Dialog | Escape | Closes |
| Select | Arrow Up/Down | Navigate options |
| Tabs | Arrow Left/Right | Switch tabs |
| Combobox | Escape | Closes list |
These contracts are tested with Playwright:
// Dialog.a11y.e2e.ts
test('Dialog closes on Escape', async ({ page }) => {
await page.getByRole('button', { name: 'Open dialog' }).click();
await expect(page.getByRole('dialog')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByRole('dialog')).not.toBeVisible();
});
The Living System
A design system is never finished. It evolves as your product evolves, as your team grows, and as the web platform changes. The systems that survive are the ones that treat maintenance as a first-class activity — not something you do "when there is time."
Invest in your CI pipeline so changes are safe. Invest in your documentation so decisions are transparent. Invest in your governance so the system stays coherent. And invest in your measurement so you know whether the system is actually delivering on its promise: faster shipping with fewer bugs and a more consistent user experience.
The bar is not perfection. The bar is a system that your teammates reach for by default because it makes their work faster and better than the alternative. Build that, and the adoption problem takes care of itself.