Design System Engineering

Build and maintain a design system that scales across teams, products, and platforms. Covers component API design, token architecture, documentation-driven development, versioning, and adoption strategies.

Design system engineering is the discipline of building, maintaining, and evolving a shared, scalable foundation of reusable UI components, design tokens, and documentation that enables consistent, high-quality user experiences across a portfolio of products. It matters when teams across an organisation—frontend, design, product, and QA—must deliver cohesive interfaces without reinventing the wheel, and when changes to visual language or interaction patterns must propagate reliably across dozens of applications.

Building the Core

The foundation of a design system is its component library. Components must be versioned, documented, tested, and deployed independently. The most common workflow begins with a component being authored in a monorepo using a tool like Turborepo, Nx, or Lerna. A component lives in packages/ui/Button, with a file structure that includes source files, storybook stories, tests, and a package.json with main, module, and types fields.

{
  "name": "@company/ui-button",
  "version": "1.2.0",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "tsc",
    "test": "jest",
    "dev": "tsc --watch",
    "storybook": "start-storybook -p 6006",
    "build-storybook": "build-storybook"
  }
}

The build script runs tsc with a tsconfig.build.json that emits both CommonJS and ES modules:

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "outDir": "dist",
    "module": "esnext",
    "target": "es2020",
    "declaration": true,
    "declarationMap": true,
    "emitDeclarationOnly": true,
    "skipLibCheck": true
  },
  "include": [
    "src/**/*"
  ]
}

When a developer adds a new prop or refactors a component, they must write tests using Jest with React Testing Library:

import { render, screen } from '@testing-library/react';
import { Button } from './Button';

test('renders with children', () => {
  render(<Button>Click me</Button>);
  expect(screen.getByText('Click me')).toBeInTheDocument();
});

test('applies variant class when passed', () => {
  render(<Button variant="primary">Submit</Button>);
  expect(screen.getByRole('button')).toHaveClass('btn-primary');
});

The test suite runs on every commit and in CI. A failed test produces output like:

 FAIL  src/Button.test.tsx
  ● Button component
    ✕ renders with children
    ✕ applies variant class when passed

  ● Button component › renders with children
    expect(received).toBeInTheDocument()

    Received: null

    Expected: not null

      4 |   render(<Button>Click me</Button>);
      5 |   expect(screen.getByText('Click me')).toBeInTheDocument();
    > 6 | });
        |                  ^
      7 | 

    console.log
      Error: Cannot find element with text: Click me

This output reveals a missing jest.setup.js configuration that sets up the DOM environment:

import { configure } from '@testing-library/react';

configure({ testIdAttribute: 'data-test-id' });

Without this, getByText fails silently when data-test-id is not used.

Versioning and Publishing

Components are versioned using semantic versioning. When a breaking change is introduced—say, a Button now requires a children prop and deprecates label—the package.json version is updated, and a CHANGELOG.md is updated with a clear entry:

## [1.3.0] - 2025-03-10

### Breaking Changes

- `Button` now requires `children` prop; `label` is deprecated and will be removed in 2.0.
- `Button` `size` prop now accepts `xs`, `sm`, `md`, `lg`, `xl` instead of `small`, `large`.

### Features

- Added `Button.Group` component.

The version is published to npm using npm publish, triggered by a GitHub Actions workflow:

- name: Publish to npm
  run: |
    npm version ${{ inputs.version }}
    npm publish --access public
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

The package.json is updated with version, repository, and publishConfig fields:

{
  "publishConfig": {
    "access": "public",
    "registry": "https://registry.npmjs.org"
  },
  "repository": {
    "type": "git",
    "url": "git+https://github.com/company/design-system.git"
  }
}

A misconfigured publishConfig can cause packages to be published to a private registry even when access: public is declared, leading to components being unavailable in node_modules until the correct npm config set registry is applied.

Storybook for Living Documentation

Storybook is used to visualize components in isolation. A stories/Button.stories.tsx file defines multiple variants:

import { Button } from '../src/Button';

export default {
  title: 'Components/Button',
  component: Button,
};

export const Primary = {
  args: {
    children: 'Primary Button',
    variant: 'primary',
  },
};

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

export const Large = {
  args: {
    children: 'Large Button',
    size: 'large',
  },
};

export const WithIcon = {
  args: {
    children: 'Save',
    icon: 'save',
    variant: 'success',
  },
};

These stories are served via start-storybook -p 6006, and the URL http://localhost:6006/?path=/story/components-button--primary allows developers to explore component variants interactively.

Each story is automatically tested via Chromatic, which captures screenshots and compares them against a baseline. A failed visual regression produces a diff image and a report:

Error: Visual regression failed
  Expected: /chromatic/expected/primary.png
  Actual: /chromatic/actual/primary.png
  Diff: /chromatic/diff/primary.png
  Changes: 3 pixels, 47% coverage

The most common failure is a mismatch due to missing or incorrect font-family or font-size in the Storybook preview. This occurs when the preview.ts file does not explicitly include a @font-face rule or when globalStyles are not applied.

Design Tokens and CSS Architecture

Design tokens—colors, spacing, typography—are defined in a tokens directory using JSON:

{
  "color": {
    "primary": "#0070f3",
    "secondary": "#6a6a6a",
    "success": "#3cb371"
  },
  "spacing": {
    "xs": "0.25rem",
    "sm": "0.5rem",
    "md": "1rem",
    "lg": "1.5rem"
  },
  "typography": {
    "fontFamily": "Inter, system-ui, sans-serif",
    "fontSize": {
      "sm": "0.875rem",
      "md": "1rem",
      "lg": "1.125rem"
    }
  }
}

These tokens are compiled into CSS custom properties via a script using PostCSS and postcss-custom-properties:

const postcss = require('postcss');
const customProperties = require('postcss-custom-properties');

const tokens = require('./tokens.json');

const css = postcss([
  customProperties({ preserve: true })
]).process(tokens, { from: undefined });

fs.writeFileSync('dist/tokens.css', css.css);

The resulting CSS is imported in index.css:

:root {
  --color-primary: #0070f3;
  --color-secondary: #6a6a6a;
  --spacing-md: 1rem;
  --typography-font-family: Inter, system-ui, sans-serif;
}

button {
  padding: var(--spacing-md);
  color: var(--color-primary);
  font-size: var(--typography-font-size-md);
}

This approach fails silently when --spacing-md is defined in tokens.json but not in the compiled CSS, due to a typo in the key ("spacing-md" vs "spacingMd"). The error is not caught by Jest or Storybook until a designer notices inconsistent spacing between components.

Integration with the Frontend Monorepo

When the design system is used in an application, it is installed via npm install @company/ui-button @company/ui-card. The application’s tailwind.config.js imports the design system’s tokens.css and extends the theme:

const { join } = require('path');

module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  theme: {
    extend: {
      colors: {
        primary: 'var(--color-primary)',
        secondary: 'var(--color-secondary)',
      },
      spacing: {
        xs: 'var(--spacing-xs)',
        sm: 'var(--spacing-sm)',
        md: 'var(--spacing-md)',
        lg: 'var(--spacing-lg)',
      },
    },
  },
  plugins: [
    require('tailwindcss'), 
    require('postcss-nested'),
  ],
  plugins: [require('postcss-utilities')],
  plugins: [require('postcss-pxtorem')],
  plugins: [require('postcss-custom-properties')],
  plugins: [require('postcss-import')({
    addDependencyTo: 'package.json',
    resolve: (id, basedir) => {
      return join(basedir, 'node_modules', id);
    }
  })],
};

A common failure is that tailwind.config.js is not correctly resolved during build. The content array must include node_modules/@company/ui-button for components to be included in the Tailwind build, but a missing node_modules path leads to a tailwind build that misses custom classes.

Component Variants and State Management

Component variants—such as Button with icon, loading, disabled, or variant—are managed through a variant object in Button.tsx:

export type ButtonProps = {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary' | 'success' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  icon?: string;
  loading?: boolean;
  disabled?: boolean;
  onClick?: () => void;
};

Each variant is defined in a variantMap object, which is used to generate class names:

const variantMap = {
  primary: 'bg-blue-600 text-white hover:bg-blue-700',
  secondary: 'bg-gray-200 text-gray-800 hover:bg-gray-300',
  success: 'bg-green-600 text-white hover:bg-green-700',
  danger: 'bg-red-600 text-white hover:bg-red-700',
};

const sizeMap = {
  sm: 'px-2 py-1 text-sm',
  md: 'px-4 py-2 text-base',
  lg: 'px-6 py-3 text-lg',
};

function Button({ children, variant = 'primary', size = 'md', icon, loading, disabled, onClick }: ButtonProps) {
  const classes = [
    'inline-flex items-center justify-center rounded',
    variantMap[variant],
    sizeMap[size],
    disabled ? 'opacity-50 cursor-not-allowed' : 'hover:opacity-90',
    loading ? 'pointer-events-none' : '',
  ].join(' ');

  return (
    <button className={classes} onClick={onClick} disabled={disabled}>
      {icon && <span className="mr-2">{icon}</span>}
      {loading ? <span className="animate-spin">⋯</span> : children}
    </button>
  );
}

A subtle failure occurs when loading is true but the pointer-events-none class is not applied. The Button remains clickable, but the loading state is not visually clear. The fix is to ensure the loading state is managed by a useEffect in the component, and the onClick handler is not called during loading.

CI/CD and Release Workflows

A release is triggered by a release branch that merges into main. A GitHub Actions workflow runs npm run build, npm run test, npm run build-storybook, and npm run publish:

- name: Release
  if: github.ref == 'refs/heads/main'
  run: |
    npm run build
    npm run test
    npm run build-storybook
    npm run publish
  env:
    NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
    CHROMATIC_PROJECT_ID: ${{ secrets.CHROMATIC_PROJECT_ID }}
    CHROMATIC_TOKEN: ${{ secrets.CHROMATIC_TOKEN }}

After publishing, Chromatic automatically deploys a new version of Storybook and creates a PR with a changelog and visual diff. The publish script uses npm version with --preid=beta for pre-releases:

npm version patch --preid=beta
npm publish --tag beta

This results in @company/[email protected]. The --tag beta creates a beta dist-tag on npm, so npm install @company/ui-button@beta installs the latest beta version.

A common mistake is not bumping the version in the correct package. A change to Button is committed, but @company/ui-button is not versioned before publishing, leading to the package.json version being stale and the dist folder containing an older build.

Summary

Design system engineering is not just about creating components. It is about ensuring they are discoverable, testable, versioned, documented, and integrated into the broader application ecosystem. The discipline thrives on consistency in tooling, configuration, and workflow. The most critical failure modes—misconfigured package.json, missing tailwind.config.js paths, unapplied design tokens, untested variants, and untagged releases—are all silent until they break in production. The most valuable signal is a failed visual regression in Chromatic, which reveals that the design system’s promise of consistency is not just a document, but a living, evolving foundation.

This page was rewritten on 18 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.