Component Library Design

Production-grade guide to component library design covering architecture patterns, implementation strategies, testing approaches, and operational best practices for enterprise engineering teams.

A component library is a curated collection of reusable, consistent UI elements—buttons, cards, forms, modals—implemented with a shared codebase and design language. It matters when multiple teams or applications share a visual language and need to reduce duplication, accelerate development, and enforce design and accessibility standards. This page details how to design a component library that is robust, maintainable, and integrated into the frontend development workflow.

Foundational Principles

Components are not just code snippets. They are encapsulated, testable, documented, and versioned units of UI. A well-designed library ensures that every component behaves identically across projects, scales across teams, and evolves with a clear process for change.

The core principles are:

These principles are enforced through a disciplined workflow: write, test, document, publish, and consume.

Developing Components

Components begin in a monorepo, typically managed with lerna, pnpm workspaces, or yarn workspaces. Each component is a separate package under packages/, with its own package.json, src/, test/, stories/, and docs/.

// packages/button/package.json
{
  "name": "@acme/button",
  "version": "1.2.0",
  "main": "dist/index.js",
  "module": "dist/index.esm.js",
  "types": "dist/index.d.ts",
  "files": [
    "dist"
  ],
  "peerDependencies": {
    "react": ">=16.8.0",
    "react-dom": ">=16.8.0"
  }
}

Each component exports a primary React component and may include utility functions, theme hooks, and configuration objects.

// packages/button/src/Button.jsx
import React from 'react';
import { buttonStyles } from './styles.css';

export const Button = ({
  children,
  variant = 'primary',
  size = 'medium',
  fullWidth = false,
  disabled = false,
  onClick,
  ...props
}) => {
  const className = buttonStyles({ variant, size, fullWidth, disabled });

  return (
    <button
      className={className}
      disabled={disabled}
      onClick={onClick}
      {...props}
    >
      {children}
    </button>
  );
};

Button.displayName = 'Button';

Configuration and Theming

Theming is achieved through a shared theme object passed to a ThemeProvider or via CSS custom properties. The library defines a defaultTheme and allows consumers to override it.

// packages/core/src/theme.js
export const defaultTheme = {
  colors: {
    primary: '#0070f3',
    secondary: '#6c757d',
    success: '#28a745',
    danger: '#dc3545',
  },
  spacing: {
    xs: '0.25rem',
    sm: '0.5rem',
    md: '1rem',
    lg: '1.5rem',
    xl: '2rem',
  },
  fontSizes: {
    sm: '0.875rem',
    md: '1rem',
    lg: '1.25rem',
    xl: '1.5rem',
  },
};

// Usage in a component
import { defaultTheme } from '@acme/core/theme';

<Button variant="primary" size="lg" theme={defaultTheme} />

Themes can be merged via a mergeTheme utility, and consumers can define custom themes in theme.js files.

Storybook Integration

Storybook is the standard for component documentation and interactive development. It enables developers to explore a component in isolation, test different props, and view variants.


npx storybook dev -p 6006

Each component has a stories/index.stories.jsx file.

// packages/button/stories/index.stories.jsx
import { Button } from '../src/Button';

export default {
  title: 'Components/Button',
  component: Button,
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'outline'],
    },
    size: {
      control: 'radio',
      options: ['small', 'medium', 'large'],
    },
    fullWidth: { control: 'boolean' },
    disabled: { control: 'boolean' },
  },
};

const Template = (args) => <Button {...args} />;

export const Primary = Template.bind({});
Primary.args = {
  children: 'Click me',
  variant: 'primary',
  size: 'medium',
};

export const Secondary = Template.bind({});
Secondary.args = {
  children: 'Learn more',
  variant: 'secondary',
  size: 'large',
};

export const WithIcon = Template.bind({});
WithIcon.args = {
  children: (
    <>
      <span className="icon">🔍</span>
      Search
    </>
  ),
  variant: 'outline',
  size: 'small',
};

Storybook supports knobs, controls, docs, and canvas views. It generates a static site at storybook-static/ and integrates with CI via build-storybook.

Testing Strategy

Components must be tested at three levels:

  1. Unit tests with Jest.
  2. Visual regression tests with Playwright or Percy.
  3. End-to-end tests with Cypress or Playwright.
# Run unit tests
npx jest --coverage --watchAll

# Run visual regression tests
npx playwright test --project=chromium --config=playwright.config.js
// packages/button/test/Button.test.js
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from '../src/Button';

describe('Button', () => {
  it('renders children correctly', () => {
    render(<Button>Submit</Button>);
    expect(screen.getByText('Submit')).toBeInTheDocument();
  });

  it('applies variant and size classes', () => {
    render(<Button variant="primary" size="large">Save</Button>);
    const button = screen.getByText('Save');
    expect(button).toHaveClass('btn--primary btn--large');
  });

  it('handles click event', () => {
    const onClick = jest.fn();
    render(<Button onClick={onClick}>Click me</Button>);
    fireEvent.click(screen.getByText('Click me'));
    expect(onClick).toHaveBeenCalledTimes(1);
  });
});

Visual Regression

Visual tests capture screenshots of components and compare them across changes.

// playwright.config.js
const config = {
  projects: [
    {
      name: 'chromium',
      use: { browserName: 'chromium', viewport: { width: 1200, height: 800 } },
    },
    {
      name: 'firefox',
      use: { browserName: 'firefox', viewport: { width: 1200, height: 800 } },
    },
  ],
  use: {
    screenshot: 'on',
    video: 'on',
  },
};

export default config;

When a component changes, the test suite captures a new baseline and flags mismatches.

Publishing and Versioning

Components are published to a private or public npm registry. Each package is versioned independently and follows semantic versioning.

# Build all packages
pnpm build

# Publish to npm
pnpm publish --access public --tag latest

# Publish specific packages
pnpm publish packages/button packages/form --access public --tag next

A publish-changes script automates the release process:

// package.json
{
  "scripts": {
    "publish-changes": "lerna publish --conventional-commits --yes --no-git-tag-version --no-push"
  }
}

This command:

Changelog Generation

Changelog entries are extracted from commit messages using conventional commits:

feat(button): add loading state

Adds a loading prop to Button that shows a spinner.

BREAKING CHANGE: `size` prop now accepts `small`, `medium`, `large`, `xlarge`.

The resulting changelog is published to the component library’s documentation site.

Consuming the Library

Consumers install the library and use components in their apps.

npm install @acme/button @acme/form @acme/core
// src/App.jsx
import { Button, Input, Card } from '@acme/button';
import { Card as FormCard } from '@acme/form';
import { defaultTheme } from '@acme/core/theme';
import { ThemeProvider } from '@acme/core';

function App() {
  return (
    <ThemeProvider theme={defaultTheme}>
      <div className="app">
        <Card title="User Profile">
          <Input label="Name" placeholder="Enter your name" />
          <Button variant="primary">Save</Button>
        </Card>
      </div>
    </ThemeProvider>
  );
}

Dependency Management

Consumers may have conflicting versions of shared dependencies (React, CSS-in-JS libraries, etc.). The library uses peerDependencies to ensure consistency.

// package.json (consumer app)
{
  "peerDependencies": {
    "react": "^18.0.0",
    "react-dom": "^18.0.0"
  },
  "devDependencies": {
    "@acme/button": "^1.2.0",
    "@acme/form": "^1.0.0"
  }
}

When the library uses emotion for styling, and the app uses styled-components, both libraries coexist without conflicts.

Integration with Build Tools

The library is integrated into the frontend build pipeline using webpack, vite, or rollup.

// vite.config.js
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { join } from 'path';

export default defineConfig({
  plugins: [react()],
  build: {
    outDir: 'dist',
    lib: {
      entry: join(__dirname, 'packages/button/src/Button.jsx'),
      name: 'Button',
      fileName: 'button',
    },
    rollupOptions: {
      external: ['react', 'react-dom'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
        },
      },
    },
  },
});

When a consumer uses Vite, the library is automatically tree-shaken and optimized.

Common Pitfalls and Sharp Edges

1. Missing displayName in React components

Without displayName, Storybook and React DevTools show components as UnnamedComponent, making debugging harder.

Button.displayName = 'Button';

2. Inconsistent CSS naming

Using btn, btn-primary, button--primary, and b-primary across components leads to confusion. The library enforces a naming convention: component--variant--size.

3. Theme merging issues

When consumers pass a theme with nested objects, and the library expects flat keys, merging fails silently. The library provides a mergeTheme utility to resolve this.

4. Storybook not reflecting prop changes

A Storybook story may define args, but the argTypes are not applied unless args are explicitly exported and argTypes are correctly configured.

5. Visual regression test flakiness

Tests fail on CI due to timing issues, font loading, or dynamic content. Adding waitFor and expect assertions helps.

await page.waitForLoadState('networkidle');
await expect(page).toHaveScreenshot('button-primary-large.png');

6. Missing TypeScript types

Consumers import a component but get TS2307: Cannot find module '...' because types entry in package.json is missing.

7. Version mismatch in monorepo

A consumer uses @acme/[email protected], but the library has a breaking change in @acme/[email protected] that isn’t reflected in the button package. The solution is to use lerna bootstrap or pnpm install to hoist shared dependencies.

8. Missing documentation

Stories are not linked to a central documentation site. The library uses @storybook/addon-docs to generate Markdown-based documentation from docs/ files.

// packages/button/docs/Usage.mdx
import { Meta, Story, Preview } from '@storybook/react';

<Meta title="Components/Button/Usage" component={Button} />

# Button Usage

Use `Button` for actions and navigation.

<Preview>
  <Story name="Primary" />
</Preview>

Summary

A component library is not a collection of code. It is a living system of design, code, testing, documentation, and deployment. It succeeds when developers can use a component without reading its source, when changes ripple across projects, and when visual consistency is maintained across apps and teams. The design of the library is defined by the process: how components are authored, tested, documented, versioned, and consumed. The sharp edges—missing displayName, inconsistent CSS, flaky visual tests, and version mismatches—are not quirks but critical failure modes in production.

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.