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.
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 does not belong: one-off marketing sections, page layouts tightly coupled to a route.
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;
}
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>
);
}
Dialog + DialogHeader beats showFooterBorder.ButtonHTMLAttributes so type, name, and form work.variant + size, not isPrimary and isLarge.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.
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.
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"
}
}
Use Changesets or semantic-release so every PR declares patch, minor, or major.
A library dies if it is harder than copying JSX.
no-restricted-syntax or a custom ESLint rule).The library is successful when product teams stop asking "which padding do we use?" and start asking "does this pattern exist yet?"