How to Build a Component Library with React | Full Stack Guide
Every growing product ends up duplicating buttons, modals, and form inputs across repos. A shared component library solves this — you build once, test once, and every team consumes the same components. This guide covers the full lifecycle: project setup with TypeScript, component development with Storybook, proper package exports, and publishing to npm.
Prerequisites
- -Node.js 20+
Required for the latest tooling and ESM support in the build pipeline.
- -React & TypeScript Experience
You should be comfortable writing React components with TypeScript props, generics, and forwardRef patterns.
- -npm Account
Required to publish your library. Free accounts work for public packages; paid for private scoped packages.
- -Basic Storybook Knowledge (Optional)
Familiarity with Storybook helps, but this guide covers setup from scratch.
Initialize the Project with TypeScript and Vite
Scaffold a library project using Vite's library mode. Vite produces clean ESM output with tree-shaking support. Set up the directory structure with separate folders for components, hooks, and utilities. Configure TypeScript with strict mode and declaration file generation.
npm create vite@latest my-ui -- --template react-ts
cd my-ui
# Install build and dev dependencies
npm install -D vite-plugin-dts vitest @testing-library/react @testing-library/jest-dom jsdom
# Create library structure
mkdir -p src/components/Button src/components/Input src/components/Modal
mkdir -p src/hooks src/utilsTip: Use Vite's library mode instead of a dedicated bundler like Rollup — Vite wraps Rollup with better defaults.
Tip: Keep each component in its own directory with an index.ts barrel file for clean imports.
Configure Vite for Library Builds with Type Declarations
Set up vite.config.ts in library mode to produce ESM output with external React dependencies. The vite-plugin-dts plugin generates .d.ts declaration files from your TypeScript source. Configure package.json exports so consumers can import individual components or the full library.
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import dts from 'vite-plugin-dts';
import { resolve } from 'path';
export default defineConfig({
plugins: [
react(),
dts({
include: ['src'],
rollupTypes: true,
}),
],
build: {
lib: {
entry: resolve(__dirname, 'src/index.ts'),
formats: ['es'],
fileName: 'index',
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
preserveModules: true,
preserveModulesRoot: 'src',
entryFileNames: '[name].js',
},
},
sourcemap: true,
minify: false,
},
});
// package.json — key fields
// {
// "name": "@scope/my-ui",
// "version": "0.1.0",
// "type": "module",
// "main": "dist/index.js",
// "types": "dist/index.d.ts",
// "files": ["dist"],
// "exports": {
// ".": {
// "import": "./dist/index.js",
// "types": "./dist/index.d.ts"
// }
// },
// "peerDependencies": {
// "react": "^18.0.0 || ^19.0.0",
// "react-dom": "^18.0.0 || ^19.0.0"
// },
// "scripts": {
// "build": "tsc && vite build",
// "test": "vitest",
// "storybook": "storybook dev -p 6006",
// "prepublishOnly": "npm run build"
// }
// }Tip: Set preserveModules: true so bundlers can tree-shake individual components — consumers only ship what they import.
Tip: Don't minify library code — the consumer's bundler handles minification. Readable output helps debugging.
Build Type-Safe Components with Proper Patterns
Create components following library-grade patterns: forwardRef for all components, polymorphic 'as' props for flexibility, and compound component patterns for complex UI. Each component gets an explicit props interface exported alongside the component so consumers can extend or type-check against it.
// src/components/Button/Button.tsx
import {
forwardRef,
type ButtonHTMLAttributes,
type ElementType,
type ComponentPropsWithRef,
} from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'solid' | 'outline' | 'ghost';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
(
{
variant = 'solid',
size = 'md',
isLoading = false,
disabled,
children,
style,
...props
},
ref
) => {
const baseStyles: React.CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
fontWeight: 500,
borderRadius: '0.375rem',
transition: 'all 150ms ease',
cursor: disabled || isLoading ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1,
...style,
};
const sizeStyles: Record<string, React.CSSProperties> = {
sm: { padding: '0.375rem 0.75rem', fontSize: '0.875rem' },
md: { padding: '0.5rem 1rem', fontSize: '1rem' },
lg: { padding: '0.75rem 1.5rem', fontSize: '1.125rem' },
};
const variantStyles: Record<string, React.CSSProperties> = {
solid: { backgroundColor: '#3b82f6', color: 'white', border: 'none' },
outline: {
backgroundColor: 'transparent',
color: '#3b82f6',
border: '1px solid #3b82f6',
},
ghost: {
backgroundColor: 'transparent',
color: '#3b82f6',
border: 'none',
},
};
return (
<button
ref={ref}
disabled={disabled || isLoading}
style={{ ...baseStyles, ...sizeStyles[size], ...variantStyles[variant] }}
aria-busy={isLoading}
{...props}
>
{isLoading ? (
<>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
style={{ marginRight: '0.5rem', animation: 'spin 1s linear infinite' }}
>
<circle
cx="8" cy="8" r="6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeDasharray="32"
strokeDashoffset="12"
/>
</svg>
Loading...
</>
) : (
children
)}
</button>
);
}
);
Button.displayName = 'Button';
// src/components/Button/index.ts
export { Button, type ButtonProps } from './Button';Tip: Export props interfaces alongside components — consumers need them for wrapper components and generic utilities.
Tip: Always set displayName when using forwardRef so React DevTools shows a meaningful name instead of 'Anonymous'.
Set Up Storybook for Interactive Documentation
Install Storybook and write stories for every component variant, state, and edge case. Storybook serves as both documentation and a development environment. Use CSF3 format with the play function for interaction testing and the autodocs feature for automatic API documentation from your TypeScript props.
// First, install Storybook:
// npx storybook@latest init
// src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react';
import { fn, userEvent, within, expect } from '@storybook/test';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['solid', 'outline', 'ghost'],
},
size: {
control: 'select',
options: ['sm', 'md', 'lg'],
},
},
args: {
onClick: fn(),
children: 'Button',
},
};
export default meta;
type Story = StoryObj<typeof meta>;
export const Solid: Story = {
args: { variant: 'solid' },
};
export const Outline: Story = {
args: { variant: 'outline' },
};
export const Ghost: Story = {
args: { variant: 'ghost' },
};
export const Loading: Story = {
args: { isLoading: true },
};
export const Disabled: Story = {
args: { disabled: true },
};
export const AllSizes: Story = {
render: () => (
<div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
),
};
// Interaction test
export const ClickTest: Story = {
args: { children: 'Click me' },
play: async ({ canvasElement, args }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole('button'));
await expect(args.onClick).toHaveBeenCalledOnce();
},
};Tip: Use the tags: ['autodocs'] to generate documentation pages from your TypeScript props automatically.
Tip: Write play functions for interaction tests — Storybook runs them in the browser alongside your visual stories.
Add Unit Tests with Vitest and Testing Library
Write unit tests that verify component behavior, accessibility, and edge cases. Vitest integrates natively with Vite's config, so there's zero extra setup. Testing Library's render and screen utilities encourage testing user-visible behavior rather than implementation details.
// vitest.config.ts
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: './src/test-setup.ts',
css: true,
},
});
// src/test-setup.ts
import '@testing-library/jest-dom/vitest';
// src/components/Button/Button.test.tsx
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, it, expect, vi } from 'vitest';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});
it('calls onClick when clicked', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button onClick={onClick}>Click</Button>);
await user.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledOnce();
});
it('does not call onClick when disabled', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button onClick={onClick} disabled>Click</Button>);
await user.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});
it('shows loading state with aria-busy', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});
it('disables the button when loading', () => {
render(<Button isLoading>Submit</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('forwards ref to the button element', () => {
const ref = { current: null } as React.RefObject<HTMLButtonElement | null>;
render(<Button ref={ref}>Ref</Button>);
expect(ref.current).toBeInstanceOf(HTMLButtonElement);
});
});Tip: Use userEvent.setup() instead of fireEvent — it simulates real user interactions including focus and keyboard events.
Tip: Test accessibility attributes (aria-busy, aria-expanded, role) alongside functional behavior.
Create the Barrel Export and Build the Library
Set up a clean barrel export file that re-exports every component, hook, and type from a single entry point. This gives consumers a clean import API. Run the build and verify the output structure includes ES modules, type declarations, and source maps.
// src/index.ts
// Components
export { Button, type ButtonProps } from './components/Button';
export { Input, type InputProps } from './components/Input';
export { Modal, type ModalProps } from './components/Modal';
// Hooks
export { useClickOutside } from './hooks/useClickOutside';
export { useMediaQuery } from './hooks/useMediaQuery';
// src/hooks/useClickOutside.ts
import { useEffect, useRef } from 'react';
export function useClickOutside<T extends HTMLElement>(
handler: () => void
) {
const ref = useRef<T>(null);
useEffect(() => {
function handleClick(event: MouseEvent) {
if (ref.current && !ref.current.contains(event.target as Node)) {
handler();
}
}
document.addEventListener('mousedown', handleClick);
return () => document.removeEventListener('mousedown', handleClick);
}, [handler]);
return ref;
}
// src/hooks/useMediaQuery.ts
import { useState, useEffect } from 'react';
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const mql = window.matchMedia(query);
setMatches(mql.matches);
function handleChange(e: MediaQueryListEvent) {
setMatches(e.matches);
}
mql.addEventListener('change', handleChange);
return () => mql.removeEventListener('change', handleChange);
}, [query]);
return matches;
}Tip: Always export type interfaces alongside components — consumers need them for wrapper typing and generic patterns.
Tip: Run 'npm pack --dry-run' before publishing to see exactly which files will be included in the package.
Publish to npm with Automated Releases
Set up the publish workflow: version the package with npm version, build, run tests, then publish. Use a prepublishOnly script to ensure the library is always built and tested before publishing. For automated releases, add a GitHub Action that publishes on tagged commits.
# .github/workflows/publish.yml
name: Publish to npm
on:
push:
tags:
- 'v*'
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
- run: npm ci
- run: npm test
- run: npm run build
- name: Verify package contents
run: npm pack --dry-run
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# Local publish workflow:
# npm version patch|minor|major
# git push origin main --tags
# GitHub Actions handles the restTip: Use npm publish --provenance to cryptographically link your package to its source repo — builds trust.
Tip: Tag releases with 'npm version patch/minor/major' which auto-creates a git tag and bumps package.json.
Tip: Test your library in a real project with 'npm link' before publishing to catch import and bundling issues.
Next Steps
- -Add a changelog generator like changesets or conventional-changelog to auto-document releases.
- -Set up Chromatic for automated visual regression testing of your Storybook stories on every PR.
- -Add a CSS-in-JS or Tailwind styling layer so components ship with styles included.
- -Create a documentation site with Storybook's static export or a dedicated docs framework like Docusaurus.
Need help building this?
I've shipped production projects with these stacks. Let's build yours together.
Let's Talk