Building Your Own Component Library with Storybook and Vite
#storybook
#vite
#design-systems
#react
#components
A well-structured component library accelerates development, enforces consistency, and improves quality across apps. In this guide, you will scaffold a modern library with Vite for fast builds and Storybook for documentation and exploratory testing. You will also add type-safe APIs, testing, theming, and a publishing pipeline.
What you’ll build
- A React-based UI library in Vite’s library mode
- Live, interactive docs with Storybook
- TypeScript types, unit tests, and a11y checks
- ESM/CJS builds, lightweight CSS, and design tokens
- An npm-ready package with versioning recommendations
Prerequisites
- Node.js 18+ and npm, yarn, or pnpm
- Familiarity with React and TypeScript
- Scaffold the project
Use your preferred package manager; examples use npm.
- Create project
mkdir acme-ui && cd acme-ui
npm init -y
npm i -D typescript vite @vitejs/plugin-react vite-plugin-dts
npm i react react-dom
npx tsc --init
- Minimal tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"jsx": "react-jsx",
"declaration": true,
"emitDeclarationOnly": false,
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": ".",
"paths": { "@acme/*": ["src/*"] },
"types": ["vite/client"]
},
"include": ["src", "vite.config.ts"]
}
- Project structure
acme-ui/
src/
components/
Button/
Button.tsx
Button.stories.tsx
Button.test.tsx
button.css
index.ts
styles.css
tokens.css
.storybook/
package.json
tsconfig.json
vite.config.ts
- Configure Vite for library mode
vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [
react(),
dts({
insertTypesEntry: true,
include: ['src']
})
],
build: {
lib: {
entry: 'src/index.ts',
name: 'AcmeUI',
formats: ['es', 'cjs'],
fileName: (format) => `acme-ui.${format}.js`
},
rollupOptions: {
external: ['react', 'react-dom']
}
}
})
- Create design tokens and base styles
src/tokens.css
:root {
--acme-radius: 0.5rem;
--acme-font: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial;
--acme-color-bg: #111827;
--acme-color-fg: #ffffff;
--acme-color-border: #1f2937;
--acme-color-accent: #3b82f6;
--acme-color-accent-contrast: #ffffff;
}
[data-theme="light"] {
--acme-color-bg: #f9fafb;
--acme-color-fg: #111827;
--acme-color-border: #e5e7eb;
}
[data-theme="dark"] {
--acme-color-bg: #0b0f19;
--acme-color-fg: #e5e7eb;
--acme-color-border: #1f2937;
}
src/styles.css
@import './tokens.css';
* { box-sizing: border-box; }
:root { color-scheme: light dark; }
- Build your first component
src/components/Button/button.css
@import '../../tokens.css';
.acme-btn {
font-family: var(--acme-font);
border-radius: var(--acme-radius);
border: 1px solid var(--acme-color-border);
padding: 0.5rem 0.875rem;
cursor: pointer;
transition: transform .05s ease, background-color .2s ease, color .2s ease, border-color .2s ease;
}
.acme-btn:active { transform: translateY(1px); }
.acme-btn--solid {
background: var(--acme-color-accent);
color: var(--acme-color-accent-contrast);
border-color: var(--acme-color-accent);
}
.acme-btn--outline {
background: transparent;
color: var(--acme-color-fg);
}
.acme-btn--ghost {
background: transparent;
border-color: transparent;
color: var(--acme-color-fg);
}
.acme-btn--sm { padding: 0.375rem 0.625rem; font-size: 0.875rem; }
.acme-btn--md { padding: 0.5rem 0.875rem; font-size: 1rem; }
.acme-btn--lg { padding: 0.75rem 1rem; font-size: 1.125rem; }
src/components/Button/Button.tsx
import * as React from 'react'
import './button.css'
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: 'solid' | 'outline' | 'ghost'
size?: 'sm' | 'md' | 'lg'
}
export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ variant = 'solid', size = 'md', className = '', ...props }, ref) => {
const cls = ['acme-btn', `acme-btn--${variant}`, `acme-btn--${size}`, className]
.filter(Boolean)
.join(' ')
return <button ref={ref} className={cls} {...props} />
}
)
Button.displayName = 'Button'
src/index.ts
export * from './components/Button/Button'
- Set up Storybook
Install and initialize:
npx storybook@latest init
# If asked, choose React and Vite
.storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite'
const config: StorybookConfig = {
framework: { name: '@storybook/react-vite', options: {} },
stories: ['../src/**/*.stories.@(tsx|mdx)'],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-a11y',
'@storybook/addon-interactions'
],
docs: { autodocs: 'tag' }
}
export default config
.storybook/preview.ts
import type { Preview } from '@storybook/react'
import '../src/styles.css'
const preview: Preview = {
parameters: {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: { expanded: true }
}
}
export default preview
- Write stories
src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'
const meta: Meta<typeof Button> = {
title: 'Components/Button',
component: Button,
tags: ['autodocs'],
args: { children: 'Click me' },
argTypes: {
variant: { control: 'select', options: ['solid', 'outline', 'ghost'] },
size: { control: 'inline-radio', options: ['sm', 'md', 'lg'] }
}
}
export default meta
type Story = StoryObj<typeof Button>
export const Solid: Story = { args: { variant: 'solid' } }
export const Outline: Story = { args: { variant: 'outline' } }
export const Ghost: Story = { args: { variant: 'ghost' } }
export const Large: Story = { args: { size: 'lg' } }
Run Storybook:
npm run storybook
- Add unit tests and accessibility checks
Install test tooling:
npm i -D vitest @testing-library/react @testing-library/jest-dom jsdom
Add test config to package.json scripts:
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"test": "vitest run",
"test:watch": "vitest"
}
Example test: src/components/Button/Button.test.tsx
import { render, screen } from '@testing-library/react'
import '@testing-library/jest-dom'
import { Button } from './Button'
it('renders a button with content', () => {
render(<Button>Submit</Button>)
expect(screen.getByRole('button', { name: 'Submit' })).toBeInTheDocument()
})
Optional Storybook test runner and visual regression:
- Use Storybook’s test runner for interaction tests.
- Use a service like Chromatic for visual testing tied to pull requests.
- Optimize exports and types for consumers
package.json (library fields)
{
"name": "@acme/ui",
"version": "0.1.0",
"private": false,
"type": "module",
"main": "./dist/acme-ui.cjs.js",
"module": "./dist/acme-ui.es.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/acme-ui.es.js",
"require": "./dist/acme-ui.cjs.js"
}
},
"files": ["dist"],
"sideEffects": [
"**/*.css"
],
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"scripts": {
"dev": "vite",
"build": "vite build",
"storybook": "storybook dev -p 6006",
"build:storybook": "storybook build",
"test": "vitest run"
}
}
Notes
- Mark CSS as side-effectful so consumers using CSS imports do not tree-shake them away.
- Keep React and ReactDOM as peerDependencies to avoid bundling them.
- Build, consume, and publish
- Build the library
npm run build
-
Local consumption for testing
- Use npm link or install via a file path in another app’s package.json: “@acme/ui”: “file:../acme-ui”
- Ensure the consuming app supports ESM and CSS imports.
-
Publish to npm
npm login
npm publish --access public
- Theming and dark mode
- Consumers can toggle themes by setting data-theme on html or body:
document.documentElement.setAttribute('data-theme', 'dark')
- Keep tokens in CSS variables to avoid recompilation and to support dynamic theming.
- Project hygiene and scale
Recommended additions
- Linting: eslint, @typescript-eslint, eslint-config-react-app or equivalent
- Formatting: prettier
- Commit hooks: husky + lint-staged
- Versioning and changelogs: Changesets or semantic-release
- Monorepo: pnpm workspaces or Nx if you manage multiple packages
- CI: run test, build, and Storybook build on pull requests
- Performance and API design tips
- Favor ESM-first exports and keep components individually tree-shakeable.
- Keep public API small; re-export from src/index.ts only what you want published.
- Avoid bundling large dependencies; use peerDependencies when feasible.
- Provide sensible defaults and progressive enhancement for accessibility.
Checklist
- Vite library mode configured with React and type generation
- Components export from src/index.ts
- Storybook running with docs, controls, a11y, and interactions
- Unit tests with Vitest and Testing Library
- Package exports and peer dependencies configured
- Theming via CSS variables
- Build and publish scripts ready
You now have a modern, documented, and testable component library workflow with Storybook and Vite. Add components, write stories as living documentation, and iterate with confidence.