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
externalin vite.config.ts - Missing .d.ts files → Verify
vite-plugin-dtsis 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