Frontend DevelopmentFeatured

Building a Production-Ready React Component Library: Step-by-Step Tutorial

Learn how to build a comprehensive React component library from scratch, following the exact approach I used to create React UI Kit. This complete tutorial covers 11 essential steps, from initial setup to deploying your components with visual regression testing and automated publishing.

December 22, 2024
25 min read
By Manjunatha C
ReactTypeScriptComponent LibraryAccessibilityChromaticStorybook

๐ŸŽฏ What We'll Build

By the end of this tutorial, you'll have created:

FeatureDescription
Monorepo Structure30+ components organized in packages
TypeScript-FirstExcellent IntelliSense and type safety
AccessibilityWCAG 2.1 AA compliance built-in
Visual TestingAutomated regression testing with Chromatic
DocumentationInteractive Storybook with examples
Production ReadyAutomated publishing and CI/CD

๐Ÿ“‹ Prerequisites

Before we start, ensure you have:

RequirementVersionPurpose
Node.js22+JavaScript runtime
pnpm10+Package manager (recommended)
GitLatestVersion control
KnowledgeBasic React & TypeScriptDevelopment foundation

๐Ÿš€ Step 1: Project Setup and Monorepo Structure

Let's start by creating our monorepo structure using pnpm workspaces:

bash
# Create the project directory mkdir react-ui-kit cd react-ui-kit # Initialize package.json npm init -y # Install pnpm globally if you haven't npm install -g pnpm # Create the monorepo structure mkdir -p packages/components mkdir -p packages/design-tokens mkdir -p packages/icons mkdir -p packages/utils mkdir apps mkdir docs

๐Ÿ“ฆ Configure the Root package.json

json
{ "name": "react-ui-kit", "private": true, "version": "0.1.0", "packageManager": "pnpm@10.12.1", "scripts": { "build": "turbo run build", "dev": "turbo run dev --filter=@react-ui-kit/components", "lint": "turbo run lint", "test": "turbo run test", "storybook": "cd packages/components && pnpm run storybook", "build-storybook": "cd packages/components && pnpm run build-storybook", "chromatic": "cd packages/components && pnpm run chromatic" }, "devDependencies": { "@changesets/cli": "^2.29.5", "turbo": "^2.5.4", "husky": "^9.1.7", "lint-staged": "^16.1.2" } }

๐Ÿ“ Create pnpm-workspace.yaml

yaml
packages: - 'packages/*' - 'apps/*' - 'docs/*'

โšก Step 2: Setup Build Tool (Turbo)

Install and configure Turbo for monorepo build orchestration:

bash
pnpm add -D turbo

Create turbo.json:

json
{ "$schema": "https://turbo.build/schema.json", "pipeline": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "lint": { "outputs": [] }, "test": { "outputs": [] }, "dev": { "cache": false, "persistent": true } } }

๐Ÿ“ฆ Step 3: Create the Core Components Package

Navigate to the components package and set it up:

bash
cd packages/components pnpm init

โš™๏ธ Configure Components package.json

json
{ "name": "@react-ui-kit/components", "version": "0.1.0", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { ".": { "import": "./dist/index.mjs", "require": "./dist/index.js", "types": "./dist/index.d.ts" }, "./styles.css": "./dist/styles.css" }, "files": ["dist"], "scripts": { "build": "tsup src/index.ts --format cjs,esm --dts", "dev": "tsup src/index.ts --format cjs,esm --dts --watch", "lint": "eslint src/**/*.{ts,tsx}", "test": "vitest", "storybook": "storybook dev -p 6006", "build-storybook": "storybook build", "chromatic": "chromatic --project-token $CHROMATIC_PROJECT_TOKEN" }, "dependencies": { "@radix-ui/react-accordion": "^1.2.11", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-button": "^1.1.6", "@radix-ui/react-dropdown-menu": "^2.1.5", "@radix-ui/react-popover": "^1.1.6", "@radix-ui/react-slot": "^1.1.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "tailwind-merge": "^2.5.4" }, "devDependencies": { "@storybook/addon-essentials": "^8.3.7", "@storybook/addon-interactions": "^8.3.7", "@storybook/addon-links": "^8.3.7", "@storybook/blocks": "^8.3.7", "@storybook/react": "^8.3.7", "@storybook/react-vite": "^8.3.7", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", "autoprefixer": "^10.4.20", "chromatic": "^11.19.2", "postcss": "^8.4.49", "react": "^18.3.1", "react-dom": "^18.3.1", "storybook": "^8.3.7", "tailwindcss": "^3.4.17", "tsup": "^8.3.5", "typescript": "^5.8.3", "vitest": "^2.1.4" }, "peerDependencies": { "react": ">=18.0.0", "react-dom": ">=18.0.0" } }

๐Ÿ”ง Step 4: TypeScript Configuration

Create tsconfig.json in the components package:

json
{ "compilerOptions": { "target": "ES2020", "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, "baseUrl": ".", "paths": { "@/*": ["./src/*"] } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist"] }

๐ŸŽจ Step 5: Tailwind CSS Setup

Create tailwind.config.js:

javascript
/** @type {import('tailwindcss').Config} */ module.exports = { content: [ "./src/**/*.{js,ts,jsx,tsx,mdx}", "./stories/**/*.{js,ts,jsx,tsx,mdx}", ], theme: { extend: { colors: { border: "hsl(var(--border))", input: "hsl(var(--input))", ring: "hsl(var(--ring))", background: "hsl(var(--background))", foreground: "hsl(var(--foreground))", primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))", }, secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))", }, destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))", }, muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))", }, accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))", }, popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))", }, card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))", }, }, borderRadius: { lg: "var(--radius)", md: "calc(var(--radius) - 2px)", sm: "calc(var(--radius) - 4px)", }, }, }, plugins: [], }

๐ŸŽจ Create Base Styles

Create src/styles/globals.css:

css
@tailwind base; @tailwind components; @tailwind utilities; @layer base { :root { --background: 0 0% 100%; --foreground: 240 10% 3.9%; --card: 0 0% 100%; --card-foreground: 240 10% 3.9%; --popover: 0 0% 100%; --popover-foreground: 240 10% 3.9%; --primary: 240 5.9% 10%; --primary-foreground: 0 0% 98%; --secondary: 240 4.8% 95.9%; --secondary-foreground: 240 5.9% 10%; --muted: 240 4.8% 95.9%; --muted-foreground: 240 3.8% 46.1%; --accent: 240 4.8% 95.9%; --accent-foreground: 240 5.9% 10%; --destructive: 0 84.2% 60.2%; --destructive-foreground: 0 0% 98%; --border: 240 5.9% 90%; --input: 240 5.9% 90%; --ring: 240 5.9% 10%; --radius: 0.5rem; } .dark { --background: 240 10% 3.9%; --foreground: 0 0% 98%; --card: 240 10% 3.9%; --card-foreground: 0 0% 98%; --popover: 240 10% 3.9%; --popover-foreground: 0 0% 98%; --primary: 0 0% 98%; --primary-foreground: 240 5.9% 10%; --secondary: 240 3.7% 15.9%; --secondary-foreground: 0 0% 98%; --muted: 240 3.7% 15.9%; --muted-foreground: 240 5% 64.9%; --accent: 240 3.7% 15.9%; --accent-foreground: 0 0% 98%; --destructive: 0 62.8% 30.6%; --destructive-foreground: 0 0% 98%; --border: 240 3.7% 15.9%; --input: 240 3.7% 15.9%; --ring: 240 4.9% 83.9%; } } @layer base { * { @apply border-border; } body { @apply bg-background text-foreground; } }

๐Ÿงฉ Step 6: Create Your First Component - Button

Let's create a robust Button component using CVA (Class Variance Authority):

๐Ÿ“ Component Structure

bash
mkdir -p src/components/ui touch src/components/ui/button.tsx touch src/lib/utils.ts touch src/index.ts

๐Ÿ› ๏ธ Utility Functions

Create src/lib/utils.ts:

typescript
import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }

๐Ÿ”˜ Button Component

Create src/components/ui/button.tsx:

typescript
import * as React from "react" import { Slot } from "@radix-ui/react-slot" import { cva, type VariantProps } from "class-variance-authority" import { cn } from "@/lib/utils" const buttonVariants = cva( "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { default: "bg-primary text-primary-foreground hover:bg-primary/90", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", outline: "border border-input bg-background hover:bg-accent hover:text-accent-foreground", secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80", ghost: "hover:bg-accent hover:text-accent-foreground", link: "text-primary underline-offset-4 hover:underline", }, size: { default: "h-10 px-4 py-2", sm: "h-9 rounded-md px-3", lg: "h-11 rounded-md px-8", icon: "h-10 w-10", }, }, defaultVariants: { variant: "default", size: "default", }, } ) export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> { asChild?: boolean } const Button = React.forwardRef<HTMLButtonElement, ButtonProps>( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button" return ( <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} /> ) } ) Button.displayName = "Button" export { Button, buttonVariants }

๐Ÿ“‹ Export Components

Update src/index.ts:

typescript
export { Button, type ButtonProps } from "./components/ui/button" export { cn } from "./lib/utils"

๐Ÿ“š Step 7: Setup Storybook for Documentation

Initialize Storybook in your components package:

bash
npx storybook@latest init

โš™๏ธ Configure Storybook

Create .storybook/main.ts:

typescript
import type { StorybookConfig } from '@storybook/react-vite'; const config: StorybookConfig = { stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], addons: [ '@storybook/addon-links', '@storybook/addon-essentials', '@storybook/addon-interactions', ], framework: { name: '@storybook/react-vite', options: {}, }, docs: { autodocs: 'tag', }, typescript: { reactDocgen: 'react-docgen-typescript', }, }; export default config;

๐ŸŽจ Configure Storybook Styles

Create .storybook/preview.ts:

typescript
import type { Preview } from '@storybook/react'; import '../src/styles/globals.css'; const preview: Preview = { parameters: { actions: { argTypesRegex: '^on[A-Z].*' }, controls: { matchers: { color: /(background|color)$/i, date: /Date$/, }, }, }, }; export default preview;

๐Ÿ“– Create Button Stories

Create src/components/ui/button.stories.tsx:

typescript
import type { Meta, StoryObj } from '@storybook/react'; import { Button } from './button'; const meta: Meta<typeof Button> = { title: 'Components/Button', component: Button, parameters: { layout: 'centered', }, tags: ['autodocs'], argTypes: { variant: { control: { type: 'select' }, options: ['default', 'destructive', 'outline', 'secondary', 'ghost', 'link'], }, size: { control: { type: 'select' }, options: ['default', 'sm', 'lg', 'icon'], }, }, }; export default meta; type Story = StoryObj<typeof meta>; export const Default: Story = { args: { children: 'Button', }, }; export const Secondary: Story = { args: { variant: 'secondary', children: 'Secondary', }, }; export const Destructive: Story = { args: { variant: 'destructive', children: 'Destructive', }, }; export const Outline: Story = { args: { variant: 'outline', children: 'Outline', }, }; export const Ghost: Story = { args: { variant: 'ghost', children: 'Ghost', }, }; export const Link: Story = { args: { variant: 'link', children: 'Link', }, }; export const Small: Story = { args: { size: 'sm', children: 'Small', }, }; export const Large: Story = { args: { size: 'lg', children: 'Large', }, }; export const Disabled: Story = { args: { disabled: true, children: 'Disabled', }, };

๐Ÿงช Step 8: Testing Setup with Vitest

Create comprehensive tests for your components:

โš™๏ธ Configure Vitest

Create vitest.config.ts:

typescript
import { defineConfig } from 'vitest/config'; import { resolve } from 'path'; export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./src/test/setup.ts'], }, resolve: { alias: { '@': resolve(__dirname, './src'), }, }, });

๐Ÿ› ๏ธ Test Setup

Create src/test/setup.ts:

typescript
import '@testing-library/jest-dom';

๐Ÿงช Button Component Tests

Create src/components/ui/button.test.tsx:

typescript
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 with default props', () => { render(<Button>Test Button</Button>); const button = screen.getByRole('button', { name: /test button/i }); expect(button).toBeInTheDocument(); expect(button).toHaveClass('bg-primary', 'text-primary-foreground'); }); it('applies variant classes correctly', () => { render(<Button variant="secondary">Secondary Button</Button>); const button = screen.getByRole('button'); expect(button).toHaveClass('bg-secondary', 'text-secondary-foreground'); }); it('applies size classes correctly', () => { render(<Button size="lg">Large Button</Button>); const button = screen.getByRole('button'); expect(button).toHaveClass('h-11', 'px-8'); }); it('handles click events', async () => { const handleClick = vi.fn(); const user = userEvent.setup(); render(<Button onClick={handleClick}>Clickable Button</Button>); const button = screen.getByRole('button'); await user.click(button); expect(handleClick).toHaveBeenCalledTimes(1); }); it('is disabled when disabled prop is true', () => { render(<Button disabled>Disabled Button</Button>); const button = screen.getByRole('button'); expect(button).toBeDisabled(); expect(button).toHaveClass('disabled:opacity-50', 'disabled:pointer-events-none'); }); it('renders as child component when asChild is true', () => { render( <Button asChild> <a href="/test">Link Button</a> </Button> ); const link = screen.getByRole('link'); expect(link).toBeInTheDocument(); expect(link).toHaveAttribute('href', '/test'); }); });

๐Ÿ‘๏ธ Step 9: Visual Testing with Chromatic

Set up visual regression testing to catch UI changes:

๐Ÿ”‘ Setup Chromatic

  1. Create account at chromatic.com
  2. Connect your GitHub repository
  3. Get your project token
  4. Add token to your CI environment variables

โš™๏ธ Configure GitHub Actions

Create .github/workflows/chromatic.yml:

yaml
name: 'Chromatic' on: push: branches: [main] pull_request: branches: [main] jobs: chromatic-deployment: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: '20' - name: Install pnpm run: npm install -g pnpm - name: Install dependencies run: pnpm install - name: Build components run: pnpm run build --filter=@react-ui-kit/components - name: Publish to Chromatic uses: chromaui/action@v11 with: token: ${{ secrets.GITHUB_TOKEN }} projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }} workingDir: packages/components buildScriptName: build-storybook

๐Ÿš€ Step 10: Publishing Setup with Changesets

Automate versioning and publishing:

๐Ÿ“ฆ Initialize Changesets

bash
npx @changesets/cli init

โš™๏ธ Configure Changesets

Edit .changeset/config.json:

json
{ "$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json", "changelog": "@changesets/cli/changelog", "commit": false, "fixed": [], "linked": [], "access": "public", "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [] }

๐Ÿ”„ CI/CD for Publishing

Create .github/workflows/release.yml:

yaml
name: Release on: push: branches: - main concurrency: ${{ github.workflow }}-${{ github.ref }} jobs: release: name: Release runs-on: ubuntu-latest steps: - name: Checkout Repo uses: actions/checkout@v4 - name: Setup Node.js uses: actions/setup-node@v4 with: node-version: 20 - name: Install pnpm run: npm install -g pnpm - name: Install Dependencies run: pnpm install - name: Build packages run: pnpm run build - name: Create Release Pull Request or Publish to npm id: changesets uses: changesets/action@v1 with: publish: pnpm run release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

๐Ÿ“ Add Release Script

Add to root package.json:

json
"scripts": { "changeset": "changeset", "version-packages": "changeset version", "release": "changeset publish" }

๐Ÿ’ก Step 11: Usage Example

Here's how to use your component library:

๐Ÿ“ฆ Installation

bash
npm install @your-org/react-ui-kit

๐ŸŽฏ Implementation

typescript
import { Button } from "@your-org/react-ui-kit" import "@your-org/react-ui-kit/styles.css" function App() { return ( <div className="p-8"> <Button variant="default" size="lg"> Primary Action </Button> <Button variant="outline" size="sm"> Secondary Action </Button> <Button variant="destructive" disabled> Disabled Action </Button> </div> ) }

โœ… Key Takeaways

  1. Start with solid foundations - TypeScript, testing, and accessibility from day one
  2. Use proven patterns - CVA for variants, Radix for complex interactions
  3. Automate everything - Testing, visual regression, and publishing
  4. Document thoroughly - Storybook stories serve as both docs and tests
  5. Think in systems - Consistent patterns across all components

This tutorial gives you a production-ready component library foundation. The real power comes from expanding it with more components while maintaining these patterns and standards.

๐Ÿ”— Additional Resources

Storybook Docs

Component documentation

Visit

Radix UI

Accessible primitives

Visit

Chromatic

Visual testing

Visit

CVA

Variant API patterns

Visit

Happy building! ๐ŸŽ‰