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.
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.
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';
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 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.
Components must be tested at three levels:
# 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 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.
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:
lerna.json for versioning strategy.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.
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>
);
}
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.
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.
displayName in React componentsWithout displayName, Storybook and React DevTools show components as UnnamedComponent, making debugging harder.
Button.displayName = 'Button';
Using btn, btn-primary, button--primary, and b-primary across components leads to confusion. The library enforces a naming convention: component--variant--size.
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.
A Storybook story may define args, but the argTypes are not applied unless args are explicitly exported and argTypes are correctly configured.
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');
Consumers import a component but get TS2307: Cannot find module '...' because types entry in package.json is missing.
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.
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>
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.
We use cookies for analytics (Google Analytics) and advertising (Google AdSense) to improve your experience and support free content. Privacy Policy