Building Your Own Component Library with Storybook and Vite

Team 6 min read

#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
  1. 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
  1. 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']
    }
  }
})
  1. 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; }
  1. 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'
  1. 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
  1. 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
  1. 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.
  1. 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.
  1. 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
  1. 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.
  1. 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
  1. 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.