Build a React Component Library in 30 Minutes

Create a production-ready React component library with TypeScript, Vite, and modern tooling. Ship reusable components fast.

Problem: You're Copy-Pasting Components Between Projects

You've built the same Button, Modal, and Input components three times across different projects. Now you need to maintain them separately and your team keeps asking for "that nice dropdown from the marketing site."

You'll learn:

  • Set up a component library with Vite and TypeScript
  • Configure tree-shaking for optimal bundle sizes
  • Publish to npm with proper versioning
  • Add Storybook for component documentation

Time: 30 min | Level: Intermediate


Why This Matters

Without a component library, teams duplicate UI code, introduce inconsistencies, and waste time rebuilding solved problems. A proper library gives you:

Benefits:

  • Single source of truth for UI components
  • Consistent design across products
  • Faster development with reusable pieces
  • Type safety with TypeScript definitions

Common pain points this solves:

  • "Which button style should I use?"
  • "How do I make this dropdown accessible?"
  • Inconsistent spacing and colors across apps

Solution

Step 1: Initialize the Project

# Create project with Vite
npm create vite@latest my-ui-library -- --template react-ts
cd my-ui-library

# Install essential dependencies
npm install -D vite-plugin-dts @types/react @types/react-dom
npm install react react-dom

Why Vite: Fast builds, native ESM support, and automatic tree-shaking. Better than Webpack for libraries in 2026.

Expected: Project created with TypeScript, React 19+, and Vite 5+ configured.


Step 2: Configure Library Build

Create vite.config.ts:

import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
import dts from 'vite-plugin-dts';

export default defineConfig({
  plugins: [
    react(),
    // Generate TypeScript declarations
    dts({ include: ['src/components'] })
  ],
  build: {
    lib: {
      // Entry point for your library
      entry: resolve(__dirname, 'src/index.ts'),
      name: 'MyUILibrary',
      // Output both ESM and UMD formats
      formats: ['es', 'umd'],
      fileName: (format) => `index.${format}.js`
    },
    rollupOptions: {
      // Externalize React - consumers provide their own
      external: ['react', 'react-dom', 'react/jsx-runtime'],
      output: {
        globals: {
          react: 'React',
          'react-dom': 'ReactDOM',
          'react/jsx-runtime': 'react/jsx-runtime'
        }
      }
    },
    // Source maps for debugging
    sourcemap: true,
    // Clear output directory
    emptyOutDir: true
  }
});

Why external dependencies: Users install React once, not bundled in every library. Prevents version conflicts and reduces bundle size by ~150KB.


Step 3: Create Your First Component

Create src/components/Button/Button.tsx:

import { ButtonHTMLAttributes, forwardRef } from 'react';
import './Button.css';

export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  /** Button visual style */
  variant?: 'primary' | 'secondary' | 'danger';
  /** Button size */
  size?: 'sm' | 'md' | 'lg';
  /** Show loading spinner */
  loading?: boolean;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', loading, children, disabled, ...props }, ref) => {
    return (
      <button
        ref={ref}
        className={`btn btn-${variant} btn-${size}`}
        disabled={disabled || loading}
        {...props}
      >
        {loading ? <span className="spinner" /> : children}
      </button>
    );
  }
);

Button.displayName = 'Button';

Why forwardRef: Allows consumers to attach refs for focus management or third-party integrations.

Create src/components/Button/Button.css:

.btn {
  /* Base styles */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  border: none;
  border-radius: 0.375rem;
  font-weight: 500;
  cursor: pointer;
  transition: all 0.15s ease;
}

.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Variants */
.btn-primary {
  background: #1976d2;
  color: white;
}

.btn-primary:hover:not(:disabled) {
  background: #1565c0;
}

.btn-secondary {
  background: #e3f2fd;
  color: #1976d2;
}

.btn-danger {
  background: #f44336;
  color: white;
}

/* Sizes */
.btn-sm {
  padding: 0.375rem 0.75rem;
  font-size: 0.875rem;
}

.btn-md {
  padding: 0.5rem 1rem;
  font-size: 1rem;
}

.btn-lg {
  padding: 0.75rem 1.5rem;
  font-size: 1.125rem;
}

/* Loading spinner */
.spinner {
  width: 1rem;
  height: 1rem;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.6s linear infinite;
}

@keyframes spin {
  to { transform: rotate(360deg); }
}

Step 4: Export Components

Create src/index.ts:

// Export all components
export { Button } from './components/Button/Button';
export type { ButtonProps } from './components/Button/Button';

// Export more components here as you build them
// export { Input } from './components/Input/Input';
// export { Modal } from './components/Modal/Modal';

Why explicit exports: Tree-shaking works better with named exports. Users only bundle what they import.


Step 5: Configure package.json

Update package.json:

{
  "name": "@yourname/ui-library",
  "version": "0.1.0",
  "type": "module",
  "main": "./dist/index.umd.js",
  "module": "./dist/index.es.js",
  "types": "./dist/index.d.ts",
  "exports": {
    ".": {
      "import": "./dist/index.es.js",
      "require": "./dist/index.umd.js",
      "types": "./dist/index.d.ts"
    },
    "./style.css": "./dist/style.css"
  },
  "files": [
    "dist"
  ],
  "scripts": {
    "build": "vite build",
    "dev": "vite",
    "prepublishOnly": "npm run build"
  },
  "peerDependencies": {
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "devDependencies": {
    "@types/react": "^18.2.0",
    "@types/react-dom": "^18.2.0",
    "@vitejs/plugin-react": "^4.2.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "typescript": "^5.5.0",
    "vite": "^5.4.0",
    "vite-plugin-dts": "^4.0.0"
  }
}

Why peerDependencies: Library doesn't bundle React. Consumer's project provides it, preventing duplicate React instances.


Step 6: Build and Test

# Build the library
npm run build

# Check output
ls dist/

Expected output:

dist/
  ├── index.es.js          # ESM bundle
  ├── index.umd.js         # UMD bundle
  ├── index.d.ts           # TypeScript types
  ├── style.css            # Component styles
  └── index.es.js.map      # Source maps

If it fails:

  • Error: "Cannot find module 'react'" → Check external in vite.config.ts
  • Missing .d.ts files → Verify vite-plugin-dts is installed
  • CSS not bundled → Ensure components import their CSS files

Step 7: Test Locally Before Publishing

Create a test app to verify your library works:

# In your library directory
npm link

# Create test app
cd ..
npm create vite@latest test-app -- --template react-ts
cd test-app
npm install
npm link @yourname/ui-library

Use in test-app/src/App.tsx:

import { Button } from '@yourname/ui-library';
import '@yourname/ui-library/style.css';

function App() {
  return (
    <div>
      <Button variant="primary" size="lg">
        Click me
      </Button>
      <Button variant="secondary" loading>
        Loading...
      </Button>
    </div>
  );
}

export default App;

Run test app:

npm run dev

You should see: Your buttons rendered with proper styles and TypeScript autocomplete working.


Step 8: Publish to npm

# Login to npm (one-time setup)
npm login

# Publish your library
npm publish --access public

For scoped packages (@yourname/ui-library): Use --access public or it defaults to private (requires paid npm account).

Version management:

# Patch release (0.1.0 → 0.1.1)
npm version patch

# Minor release (0.1.0 → 0.2.0)
npm version minor

# Major release (0.1.0 → 1.0.0)
npm version major

Optional: Add Storybook for Documentation

Storybook lets teams preview components without building apps:

npx storybook@latest init

Create 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'],
  argTypes: {
    variant: {
      control: 'select',
      options: ['primary', 'secondary', 'danger']
    },
    size: {
      control: 'select',
      options: ['sm', 'md', 'lg']
    }
  }
};

export default meta;
type Story = StoryObj<typeof Button>;

export const Primary: Story = {
  args: {
    children: 'Primary Button',
    variant: 'primary'
  }
};

export const Loading: Story = {
  args: {
    children: 'Loading...',
    loading: 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>
  )
};

Run Storybook:

npm run storybook

Verification

After publishing, install your library in a new project:

npm install @yourname/ui-library

You should see:

  • ✅ TypeScript autocomplete for props
  • ✅ Components render correctly
  • ✅ Tree-shaking works (only imported components in bundle)
  • ✅ No React version conflicts

Check bundle size:

# In your consuming app
npm run build -- --analyze

Your Button component should add <5KB gzipped to the bundle.


What You Learned

  • Vite creates optimized library builds with minimal config
  • Externalizing React prevents duplicate bundles and version conflicts
  • TypeScript definitions enable autocomplete in consuming projects
  • Tree-shaking works automatically with ESM and named exports

Limitations:

  • CSS must be imported separately (can't be auto-included)
  • Updating library requires consumers to bump versions manually
  • Breaking changes need major version bumps to avoid breaking apps

When NOT to use this:

  • Single-use components that won't be reused
  • Highly project-specific logic (use hooks instead)
  • Components that change frequently (keep in-app until stable)

Tested on React 19.0, Vite 5.4, TypeScript 5.5, Node.js 22.x, macOS & Ubuntu