WEIRDSOFT
Back to blog

Building a Design System from Scratch

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:

  1. Proposal: File an RFC with component API sketch and design mock
  2. Review: Council reviews for scope, overlap, and accessibility
  3. Implementation: Contributor builds the component following system conventions (CVA, tokens, tests, stories)
  4. Review: Design system team reviews for code quality and consistency
  5. 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:

  1. 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.
  2. 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();
  });
});
  1. 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.