Learn how to build a professional component library that combines the flexibility of React with the utility-first approach of Tailwind CSS. ๐
Project Setup ๐ฏ
First, let's set up our project with all necessary dependencies.
# Create new project
npx create-react-app my-component-library --template typescript
cd my-component-library
# Install dependencies
npm install tailwindcss postcss autoprefixer @storybook/react
npm install classnames @headlessui/react @heroicons/react
npm install -D @types/classnames tsup
# Initialize Tailwind CSS
npx tailwindcss init -p
Project Structure ๐
my-component-library/
โโโ src/
โ โโโ components/
โ โ โโโ Button/
โ โ โ โโโ Button.tsx
โ โ โ โโโ Button.stories.tsx
โ โ โ โโโ Button.test.tsx
โ โ โโโ Card/
โ โ โโโ Input/
โ โ โโโ index.ts
โ โโโ styles/
โ โ โโโ tailwind.css
โ โโโ utils/
โ โโโ helpers.ts
โโโ .storybook/
โ โโโ main.ts
โ โโโ preview.ts
โโโ tailwind.config.js
Tailwind Configuration ๐จ
// tailwind.config.js
const colors = require('tailwindcss/colors');
module.exports = {
content: ['./src/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
primary: colors.blue,
secondary: colors.gray,
success: colors.green,
warning: colors.yellow,
danger: colors.red
},
spacing: {
xs: '0.25rem',
sm: '0.5rem',
md: '1rem',
lg: '1.5rem',
xl: '2rem'
},
borderRadius: {
xs: '0.125rem',
sm: '0.25rem',
md: '0.375rem',
lg: '0.5rem',
xl: '0.75rem'
}
}
},
plugins: []
};
Button Component ๐
// src/components/Button/Button.tsx
import React from 'react';
import classNames from 'classnames';
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
isLoading?: boolean;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
children,
variant = 'primary',
size = 'md',
isLoading = false,
leftIcon,
rightIcon,
className,
disabled,
...props
}) => {
const baseStyles = 'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-offset-2 transition-colors';
const variantStyles = {
primary: 'bg-primary-600 text-white hover:bg-primary-700 focus:ring-primary-500',
secondary: 'bg-secondary-100 text-secondary-800 hover:bg-secondary-200 focus:ring-secondary-500',
danger: 'bg-danger-600 text-white hover:bg-danger-700 focus:ring-danger-500'
};
const sizeStyles = {
sm: 'px-3 py-2 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
};
const buttonStyles = classNames(
baseStyles,
variantStyles[variant],
sizeStyles[size],
{
'opacity-50 cursor-not-allowed': disabled || isLoading,
'gap-2': leftIcon || rightIcon
},
className
);
return (
<button
className={buttonStyles}
disabled={disabled || isLoading}
{...props}
>
{isLoading && (
<svg
className="animate-spin -ml-1 mr-2 h-4 w-4"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
)}
{!isLoading && leftIcon}
{children}
{!isLoading && rightIcon}
</button>
);
};
Input Component โ๏ธ
// src/components/Input/Input.tsx
import React, { forwardRef } from 'react';
import classNames from 'classnames';
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
leftIcon?: React.ReactNode;
rightIcon?: React.ReactNode;
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
(
{
label,
error,
leftIcon,
rightIcon,
className,
...props
},
ref
) => {
const inputWrapperStyles = classNames(
'relative rounded-md shadow-sm',
{
'mt-1': label
}
);
const inputStyles = classNames(
'block w-full rounded-md border-gray-300 shadow-sm focus:border-primary-500 focus:ring-primary-500 sm:text-sm',
{
'pl-10': leftIcon,
'pr-10': rightIcon,
'border-danger-300 focus:border-danger-500 focus:ring-danger-500': error
},
className
);
const iconStyles = 'absolute inset-y-0 flex items-center pointer-events-none text-gray-400';
return (
<div>
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
</label>
)}
<div className={inputWrapperStyles}>
{leftIcon && (
<div className={`${iconStyles} left-3`}>
{leftIcon}
</div>
)}
<input
ref={ref}
className={inputStyles}
{...props}
/>
{rightIcon && (
<div className={`${iconStyles} right-3`}>
{rightIcon}
</div>
)}
</div>
{error && (
<p className="mt-1 text-sm text-danger-600">
{error}
</p>
)}
</div>
);
}
);
Input.displayName = 'Input';
Card Component ๐
// src/components/Card/Card.tsx
import React from 'react';
import classNames from 'classnames';
export interface CardProps {
title?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
className?: string;
hoverable?: boolean;
}
export const Card: React.FC<CardProps> = ({
title,
footer,
children,
className,
hoverable = false
}) => {
const cardStyles = classNames(
'bg-white rounded-lg border border-gray-200 shadow-sm overflow-hidden',
{
'transition-shadow hover:shadow-lg': hoverable
},
className
);
return (
<div className={cardStyles}>
{title && (
<div className="px-4 py-5 border-b border-gray-200 sm:px-6">
{typeof title === 'string' ? (
<h3 className="text-lg font-medium leading-6 text-gray-900">
{title}
</h3>
) : (
title
)}
</div>
)}
<div className="px-4 py-5 sm:p-6">
{children}
</div>
{footer && (
<div className="px-4 py-4 border-t border-gray-200 sm:px-6">
{footer}
</div>
)}
</div>
);
};
Modal Component ๐ช
// src/components/Modal/Modal.tsx
import React, { Fragment } from 'react';
import { Dialog, Transition } from '@headlessui/react';
import { XMarkIcon } from '@heroicons/react/24/outline';
import classNames from 'classnames';
export interface ModalProps {
isOpen: boolean;
onClose: () => void;
title?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl';
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
children,
footer,
size = 'md'
}) => {
const sizeStyles = {
sm: 'sm:max-w-sm',
md: 'sm:max-w-lg',
lg: 'sm:max-w-xl',
xl: 'sm:max-w-2xl'
};
return (
<Transition.Root show={isOpen} as={Fragment}>
<Dialog
as="div"
className="fixed z-10 inset-0 overflow-y-auto"
onClose={onClose}
>
<div className="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<Dialog.Overlay className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" />
</Transition.Child>
<span
className="hidden sm:inline-block sm:align-middle sm:h-screen"
aria-hidden="true"
>
​
</span>
<Transition.Child
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
>
<div
className={classNames(
'inline-block align-bottom bg-white rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle w-full',
sizeStyles[size]
)}
>
{title && (
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<div className="flex justify-between items-center">
<Dialog.Title
as="h3"
className="text-lg font-medium text-gray-900"
>
{title}
</Dialog.Title>
<button
type="button"
className="rounded-md text-gray-400 hover:text-gray-500 focus:outline-none"
onClick={onClose}
>
<XMarkIcon className="h-6 w-6" />
</button>
</div>
</div>
)}
<div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
{children}
</div>
{footer && (
<div className="bg-gray-50 px-4 py-3 sm:px-6 sm:flex sm:flex-row-reverse">
{footer}
</div>
)}
</div>
</Transition.Child>
</div>
</Dialog>
</Transition.Root>
);
};
Storybook Setup ๐
// .storybook/main.ts
import type { StorybookConfig } from '@storybook/react-vite';
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|ts|tsx)'],
addons: [
'@storybook/addon-links',
'@storybook/addon-essentials',
'@storybook/addon-interactions'
],
framework: {
name: '@storybook/react-vite',
options: {}
}
};
export default config;
// .storybook/preview.ts
import '../src/styles/tailwind.css';
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/
}
}
};
Component 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,
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger']
},
size: {
control: 'select',
options: ['sm', 'md', 'lg']
},
isLoading: {
control: 'boolean'
}
}
};
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
children: 'Primary Button',
variant: 'primary'
}
};
export const Secondary: Story = {
args: {
children: 'Secondary Button',
variant: 'secondary'
}
};
export const Loading: Story = {
args: {
children: 'Loading Button',
isLoading: true
}
};
Testing Setup ๐งช
// src/components/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';
describe('Button', () => {
it('renders children correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});
it('handles click events', () => {
const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
fireEvent.click(screen.getByText('Click me'));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it('displays loading state', () => {
render(<Button isLoading>Click me</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});
it('applies variant classes correctly', () => {
const { container } = render(
<Button variant="secondary">Click me</Button>
);
expect(container.firstChild).toHaveClass('bg-secondary-100');
});
});
Build Configuration ๐ง
// tsup.config.ts
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/index.ts'],
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['react']
});
Package Configuration ๐ฆ
// package.json
{
"name": "my-component-library",
"version": "1.0.0",
"main": "dist/index.js",
"module": "dist/index.mjs",
"types": "dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"dev": "tsup --watch",
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"test": "jest",
"lint": "eslint src --ext .ts,.tsx"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
}
Best Practices ๐
- Use TypeScript for type safety
- Follow component composition patterns
- Implement proper testing
- Use Storybook for documentation
- Follow accessibility guidelines
- Optimize bundle size
- Use proper versioning
- Follow semantic naming
- Implement proper documentation
- Follow design system principles
Additional Resources
Building a component library requires careful consideration of reusability, maintainability, and developer experience. This guide provides a solid foundation for creating a professional component library that can be used across multiple projects.