Building a Component Library with React and Storybook
Building a Component Library with React and StorybookDecember 20, 2023

Building a Component Library with React and Storybook

Step-by-step guide to creating a scalable component library. Learn about design systems, component API design, documentation with Storybook, testing strategies, and publishing to npm.

Table of Contents

  1. Why a Component Library
  2. Design Tokens First
  3. Component API Design
  4. Storybook as the Source of Truth
  5. Testing Components
  6. Packaging and Versioning
  7. Adoption Across Teams

Why a Component Library {#why}

A component library is not a folder of random UI pieces. It is a contract: names, spacing, states, and accessibility rules that product teams can reuse without reinventing a button every sprint.

When we rebuilt a medical e-commerce UI, a shared library cut design QA cycles and kept checkout, catalog, and account pages visually consistent. Tailwind plus a small primitive set (later aligned with shadcn/ui patterns) was enough to start. Storybook made the contract visible.

What belongs in the library

  • Primitives: Button, Input, Select, Dialog, Toast
  • Patterns: Form field, empty state, data table
  • Tokens: color, type, space, radius, elevation

What does not belong: one-off marketing sections, page layouts tightly coupled to a route.

Design Tokens First {#tokens}

Tokens keep CSS from becoming a pile of magic numbers.

// tokens.js
export const tokens = {
  color: {
    bg: 'var(--color-bg)',
    fg: 'var(--color-fg)',
    brand: 'var(--color-brand)',
    danger: 'var(--color-danger)',
  },
  space: {
    1: '0.25rem',
    2: '0.5rem',
    3: '0.75rem',
    4: '1rem',
    6: '1.5rem',
    8: '2rem',
  },
  radius: {
    sm: '4px',
    md: '8px',
    lg: '12px',
  },
};

Map tokens to CSS variables so dark mode and white-label themes do not require rewriting components.

:root {
  --color-bg: #ffffff;
  --color-fg: #111827;
  --color-brand: #0f766e;
}

[data-theme='dark'] {
  --color-bg: #0b1220;
  --color-fg: #f3f4f6;
  --color-brand: #2dd4bf;
}

Component API Design {#api}

A good API is boring. Prefer a small set of props over dozens of boolean flags.

type ButtonProps = {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
  size?: 'sm' | 'md' | 'lg';
  loading?: boolean;
  disabled?: boolean;
} & React.ButtonHTMLAttributes<HTMLButtonElement>;

export function Button({
  children,
  variant = 'primary',
  size = 'md',
  loading = false,
  disabled,
  ...props
}: ButtonProps) {
  return (
    <button
      className={cn('btn', `btn-${variant}`, `btn-${size}`)}
      disabled={disabled || loading}
      aria-busy={loading || undefined}
      {...props}
    >
      {loading ? <Spinner /> : children}
    </button>
  );
}

Rules that scale

  • Compose, don't configure: Dialog + DialogHeader beats showFooterBorder.
  • Native first: extend ButtonHTMLAttributes so type, name, and form work.
  • One way to do variants: variant + size, not isPrimary and isLarge.
  • Accessible by default: focus rings, labels, and keyboard behavior are not optional add-ons.

Storybook as the Source of Truth {#storybook}

Stories are executable documentation. Designers, PMs, and engineers can review states without running the app.

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

const meta: Meta<typeof Button> = {
  title: 'Primitives/Button',
  component: Button,
  args: { children: 'Continue' },
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {};
export const Loading: Story = { args: { loading: true } };
export const Disabled: Story = { args: { disabled: true } };

Add interaction tests for behavior that screenshots miss:

import { expect, userEvent, within } from '@storybook/test';

export const SubmitsForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
    await userEvent.click(canvas.getByRole('button', { name: 'Continue' }));
    await expect(canvas.getByRole('button')).toHaveAttribute('aria-busy', 'true');
  },
};

Document a11y addons and run them in CI so contrast and ARIA issues fail the build.

Testing Components {#testing}

Storybook covers visual states. Unit and integration tests cover behavior.

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

test('does not fire click when loading', async () => {
  const onClick = jest.fn();
  render(
    <Button loading onClick={onClick}>
      Pay
    </Button>
  );

  await userEvent.click(screen.getByRole('button', { name: 'Pay' }));
  expect(onClick).not.toHaveBeenCalled();
});

Snapshot the CSS output only when the visual contract is the point. Prefer role-based queries over class names.

Packaging and Versioning {#packaging}

Treat the library like a product with a changelog.

{
  "name": "@acme/ui",
  "version": "1.4.0",
  "main": "./dist/index.cjs",
  "module": "./dist/index.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs",
      "types": "./dist/index.d.ts"
    },
    "./styles.css": "./dist/styles.css"
  },
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  }
}
  • Peer React: do not bundle React.
  • Tree-shake: export each component from its own file.
  • Semver: visual breaking changes are breaking, even if TypeScript still compiles.

Use Changesets or semantic-release so every PR declares patch, minor, or major.

Adoption Across Teams {#adoption}

A library dies if it is harder than copying JSX.

  1. Publish Storybook to a URL the whole company can open.
  2. Provide copy-paste recipes for checkout, search, and forms.
  3. Ban one-off buttons in app code via lint (no-restricted-syntax or a custom ESLint rule).
  4. Staff office hours for the first two sprints after launch.

The library is successful when product teams stop asking "which padding do we use?" and start asking "does this pattern exist yet?"