Introduction
When I first started using Tailwind CSS, I thought it was just another utility-first framework. But after building a few projects, I realized something: copy-pasting the same button classes across components gets old fast. That's when I decided to build my own UI kit on top of Tailwind.
In this article, I'll walk you through creating a reusable, maintainable UI kit using Tailwind CSS. We'll cover everything from setting up a design system to creating actual components you can drop into any project.
Why Build a Custom UI Kit?
Before we dive in, let's talk about why you'd want to do this in the first place.
Tailwind gives you utility classes, but it doesn't give you components. You could use a library like DaisyUI or Headless UI, but sometimes you need something specific to your brand or project requirements. Building your own kit means:
- Consistency across projects: Define your button styles once, use them everywhere
- Faster development: No more hunting for that perfect shadow or border radius
- Easy theming: Change colors site-wide with a config update
- Better collaboration: Designers and developers speak the same language
Setting Up Your Foundation
Let's start with a solid Tailwind configuration. This is where your design system lives.
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
Now, open tailwind.config.js and extend the default theme with your custom values:
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{html,js,jsx,ts,tsx}'],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
},
secondary: {
500: '#8b5cf6',
600: '#7c3aed',
},
},
spacing: {
'128': '32rem',
'144': '36rem',
},
borderRadius: {
'xl': '1rem',
'2xl': '1.5rem',
},
fontSize: {
'xs': ['0.75rem', { lineHeight: '1rem' }],
'sm': ['0.875rem', { lineHeight: '1.25rem' }],
'base': ['1rem', { lineHeight: '1.5rem' }],
},
},
},
plugins: [],
}
💡 Tip: Keep your color palette limited. I typically use 2-3 brand colors plus grays. More than that and decision fatigue kicks in.
Creating Base Component Styles
Here's where it gets interesting. We'll use Tailwind's @layer directive to create reusable component classes. Create a styles.css file:
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer components {
/* Button Base */
.btn {
@apply inline-flex items-center justify-center px-4 py-2
font-medium rounded-lg transition-all duration-200
focus:outline-none focus:ring-2 focus:ring-offset-2
disabled:opacity-50 disabled:cursor-not-allowed;
}
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700
focus:ring-primary-500;
}
.btn-secondary {
@apply btn bg-secondary-600 text-white hover:bg-secondary-700
focus:ring-secondary-500;
}
.btn-outline {
@apply btn border-2 border-primary-600 text-primary-600
hover:bg-primary-50 focus:ring-primary-500;
}
/* Input Base */
.input {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg
focus:outline-none focus:ring-2 focus:ring-primary-500
focus:border-transparent disabled:bg-gray-100
disabled:cursor-not-allowed;
}
.input-error {
@apply input border-red-500 focus:ring-red-500;
}
/* Card Base */
.card {
@apply bg-white rounded-xl shadow-sm border border-gray-200
overflow-hidden;
}
.card-body {
@apply p-6;
}
}
Now you can use these classes directly in your HTML:
<button class="btn-primary">Save Changes</button>
<input type="text" class="input" placeholder="Enter email" />
<div class="card">
<div class="card-body">
<h3>Card Title</h3>
<p>Card content goes here</p>
</div>
</div>
⚠️ Warning: Don't go overboard with
@layer components. If you're defining too many custom classes, you're fighting against Tailwind's utility-first approach. Use it for truly reusable patterns.
Building Component Variants
Let's create a proper button component with multiple variants. I'll show you both the CSS approach and a React component approach.
CSS-Only Approach
@layer components {
/* Size variants */
.btn-sm {
@apply btn text-sm px-3 py-1.5;
}
.btn-lg {
@apply btn text-lg px-6 py-3;
}
/* State variants */
.btn-loading {
@apply btn relative text-transparent pointer-events-none;
}
.btn-loading::after {
content: '';
@apply absolute inset-0 flex items-center justify-center;
background: inherit;
border-radius: inherit;
}
}
React Component Approach
For dynamic applications, wrapping your styles in components gives you better control:
// Button.jsx
import React from 'react';
const Button = ({
children,
variant = 'primary',
size = 'md',
loading = false,
disabled = false,
...props
}) => {
const baseClasses = 'btn';
const variants = {
primary: 'btn-primary',
secondary: 'btn-secondary',
outline: 'btn-outline',
};
const sizes = {
sm: 'btn-sm',
md: '',
lg: 'btn-lg',
};
const classes = [
baseClasses,
variants[variant],
sizes[size],
loading && 'btn-loading',
].filter(Boolean).join(' ');
return (
<button
className={classes}
disabled={disabled || loading}
{...props}
>
{loading ? (
<>
<svg className="animate-spin h-5 w-5 mr-2" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" strokeWidth="4" fill="none" />
<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>
Loading...
</>
) : children}
</button>
);
};
export default Button;
Usage:
<Button variant="primary" size="lg">Click Me</Button>
<Button variant="outline" loading>Processing...</Button>
📌 Note: The component approach is more flexible for complex UIs, but CSS-only works great for marketing sites or simple apps.
Creating a Complete Form System
Forms are where UI kits really shine. Let's build input, label, and error message components that work together:
// Input.jsx
const Input = ({
label,
error,
hint,
required = false,
...props
}) => {
const inputClasses = error ? 'input-error' : 'input';
return (
<div className="space-y-1">
{label && (
<label className="block text-sm font-medium text-gray-700">
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</label>
)}
<input className={inputClasses} {...props} />
{hint && !error && (
<p className="text-sm text-gray-500">{hint}</p>
)}
{error && (
<p className="text-sm text-red-600 flex items-center gap-1">
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
{error}
</p>
)}
</div>
);
};
// Usage
<Input
label="Email Address"
type="email"
required
hint="We'll never share your email"
error={errors.email}
/>
Common Component Patterns
Here's a table of components you'll want in most UI kits:
| Component | Key Features | Common Pitfalls |
|---|---|---|
| Button | Variants, sizes, loading state, icon support | Forgetting disabled styles, poor loading UX |
| Input | Label, error state, helper text, validation styling | Not handling autofill styles, missing focus states |
| Card | Header, body, footer sections, hover effects | Inconsistent padding, missing overflow handling |
| Modal | Backdrop, close button, scroll lock, focus trap | Not preventing body scroll, poor mobile UX |
| Badge | Colors, sizes, dismissible variant | Text overflow issues, inconsistent spacing |
| Alert | Types (success, error, warning), dismissible, icon | Accessibility issues, poor color contrast |
Managing Component Complexity
As your kit grows, organization matters. Here's a structure that's worked for me:
src/
├── components/
│ ├── ui/
│ │ ├── Button/
│ │ │ ├── Button.jsx
│ │ │ ├── Button.test.jsx
│ │ │ └── index.js
│ │ ├── Input/
│ │ ├── Card/
│ │ └── index.js (barrel export)
│ └── ...
├── styles/
│ ├── base.css
│ ├── components.css
│ └── utilities.css
└── tailwind.config.js
Your barrel export (components/ui/index.js) should look like:
export { default as Button } from './Button';
export { default as Input } from './Input';
export { default as Card } from './Card';
// ... more exports
This lets you import cleanly:
import { Button, Input, Card } from '@/components/ui';
Dark Mode Support
Dark mode is expected nowadays. Tailwind makes this easy:
// tailwind.config.js
module.exports = {
darkMode: 'class', // or 'media'
// ... rest of config
}
Update your component styles:
@layer components {
.btn-primary {
@apply btn bg-primary-600 text-white hover:bg-primary-700
dark:bg-primary-500 dark:hover:bg-primary-600;
}
.card {
@apply bg-white dark:bg-gray-800
border-gray-200 dark:border-gray-700;
}
.input {
@apply border-gray-300 dark:border-gray-600
bg-white dark:bg-gray-800
text-gray-900 dark:text-gray-100;
}
}
💡 Tip: Use
darkMode: 'class'instead of'media'so users can toggle dark mode manually. Implement the toggle with a simple React hook or vanilla JS that adds/removes adarkclass on the<html>element.
Documentation and Storybook
A UI kit without docs is just a folder of components. You don't need anything fancy. A simple README with examples works:
# Button Component
## Usage
import { Button } from '@/components/ui';
<Button variant="primary">Click me</Button>
## Props
- `variant`: 'primary' | 'secondary' | 'outline'
- `size`: 'sm' | 'md' | 'lg'
- `loading`: boolean
- `disabled`: boolean
## Examples
<Button variant="outline" size="lg">Large Outline</Button>
<Button loading>Processing...</Button>
If your team is bigger, Storybook is worth setting up:
npx storybook init
Create a story file:
// Button.stories.jsx
import Button from './Button';
export default {
title: 'UI/Button',
component: Button,
};
export const Primary = () => <Button variant="primary">Primary</Button>;
export const Loading = () => <Button loading>Loading</Button>;
Best Practices I've Learned
After building a few UI kits, here are things I wish I knew earlier:
Keep it minimal at first. Start with 5-6 components (Button, Input, Card, Modal, Alert). Add more as you need them.
Composition over configuration. Instead of a component with 20 props, build smaller components that compose together:
// Good
<Card>
<CardHeader>Title</CardHeader>
<CardBody>Content</CardBody>
</Card>
// Avoid
<Card title="Title" content="Content" hasHeader hasFooter />
Test on real projects. Your UI kit will reveal its flaws when you use it. Don't build in isolation.
Version your kit properly. If you're sharing across projects, use semantic versioning. Breaking changes to a Button component can break multiple apps.
Accessibility isn't optional. Use semantic HTML, add ARIA labels where needed, and test with a keyboard. Your future self (and users) will thank you.
Common Mistakes to Avoid
Here are issues I've run into:
Over-abstracting too early. You don't need a component for everything. Three similar buttons? Copy-paste is fine. Ten similar buttons? Time for a component.
Ignoring responsive design. Your components should work on mobile. Test different screen sizes. Use Tailwind's responsive prefixes:
<Button className="w-full md:w-auto">Responsive Button</Button>
Hardcoding colors. Always use theme colors from your config, never raw hex values. This makes theming possible later.
Not handling edge cases. What happens with really long text? Disabled state? Loading state? Think through these scenarios.
Wrapping Up
Building a custom UI kit with Tailwind isn't about reinventing the wheel. It's about creating a consistent design language that makes you faster and your code more maintainable.
Start small. Build the components you actually need. Refine them as you go. Before you know it, you'll have a solid kit that saves hours of development time.
The code examples here should give you a solid foundation. Take them, modify them, make them yours. That's the whole point.



