Building a Production-Ready Button System with React, TypeScript, and Tailwind CSS
Learn how to create a scalable and accessible button component system with variants, loading states, icons, and reusable utilities.
Building a Production-Ready Button System
Most tutorials stop at a simple `<button>` component.
Real-world applications need much more:
- Multiple variants
- Different sizes
- Loading states
- Accessibility support
- Icon integration
- Reusable styling patterns
- Type safety
In this article, we’ll build a scalable button system using:
- React
- TypeScript
- Tailwind CSS
`clsx``tailwind-merge`
Why Component Systems Matter
As applications grow, inconsistent UI becomes a serious problem.
Without a proper component system:
- Buttons look different across pages
- Styles become duplicated
- Refactoring gets painful
- Accessibility gets ignored
- Design changes take longer
A centralized button system solves these issues by creating a single source of truth.
Project Structure
Here’s a clean structure for scalable UI components:
src/
├── components/
│ └── ui/
│ ├── button.tsx
│ ├── spinner.tsx
│ └── icon.tsx
├── lib/
│ └── cn.tsInstalling Dependencies
We’ll use two tiny libraries for conditional class management.
npm install clsx tailwind-mergeCreating a Utility Function
Combining Tailwind classes manually gets messy fast.
Create a helper:
// lib/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}This utility:
- Conditionally applies classes
- Merges conflicting Tailwind utilities
- Keeps components clean
Building the Button Component
Now let’s create the actual button system.
// components/ui/button.tsx
import * as React from 'react'
import { cn } from '@/lib/cn'
type ButtonVariant =
| 'primary'
| 'secondary'
| 'outline'
| 'ghost'
| 'danger'
type ButtonSize =
| 'sm'
| 'md'
| 'lg'
interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: ButtonVariant
size?: ButtonSize
loading?: boolean
leftIcon?: React.ReactNode
rightIcon?: React.ReactNode
}
const variantStyles: Record<ButtonVariant, string> = {
primary:
'bg-blue-600 text-white hover:bg-blue-700',
secondary:
'bg-zinc-900 text-white hover:bg-zinc-800',
outline:
'border border-zinc-300 bg-white hover:bg-zinc-100',
ghost:
'hover:bg-zinc-100 text-zinc-800',
danger:
'bg-red-600 text-white hover:bg-red-700',
}
const sizeStyles: Record<ButtonSize, string> = {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-6 text-base',
}
export const Button = React.forwardRef<
HTMLButtonElement,
ButtonProps
>(
(
{
className,
variant = 'primary',
size = 'md',
loading = false,
disabled,
leftIcon,
rightIcon,
children,
...props
},
ref
) => {
return (
<button
ref={ref}
disabled={disabled || loading}
className={cn(
'inline-flex items-center justify-center gap-2 rounded-xl font-medium transition-all duration-200',
'focus:outline-none focus:ring-2 focus:ring-blue-400',
'disabled:opacity-50 disabled:pointer-events-none',
variantStyles[variant],
sizeStyles[size],
className
)}
{...props}
>
{loading && (
<svg
className="h-4 w-4 animate-spin"
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-8v4a4 4 0 00-4 4H4z"
/>
</svg>
)}
{!loading && leftIcon}
<span>{children}</span>
{!loading && rightIcon}
</button>
)
}
)
Button.displayName = 'Button'Why This Architecture Works
This structure scales extremely well because:
Variants Are Centralized
Instead of scattered class names across the app:
<Button variant="danger">
Delete Account
</Button>all styles are managed in one location.
Type Safety Prevents Mistakes
TypeScript ensures invalid variants fail immediately:
<Button variant="purple" />This produces compile-time errors instead of broken UI.
Adding Icons
Buttons often need icons for better UX.
Example:
import { Trash2 } from 'lucide-react'
<Button
variant="danger"
leftIcon={<Trash2 size={16} />}
>
Delete
</Button>This improves:
- Visual hierarchy
- Recognition speed
- Accessibility
Loading States
One of the most overlooked UI details is handling async actions.
<Button loading>
Saving Changes
</Button>Benefits:
- Prevents double submissions
- Improves perceived responsiveness
- Gives immediate user feedback
Accessibility Considerations
A production-ready component must support accessibility.
Our button already includes:
- Keyboard focus states
- Disabled handling
- Semantic HTML
- Proper button behavior
You can improve further with:
aria-label="Delete item"
aria-busy={loading}Accessibility is not optional in modern frontend development.
Supporting Polymorphism
Advanced design systems often support rendering buttons as links.
Example API:
<Button asChild>
<Link href="/dashboard">
Dashboard
</Link>
</Button>Libraries like Radix UI make this pattern easier.
Common Mistakes to Avoid
1. Hardcoding Styles Everywhere
Bad:
<button className="bg-blue-500 px-4 py-2">Good:
<Button>2. Ignoring Accessibility
Custom buttons without keyboard support create unusable interfaces.
Always test:
- Tab navigation
- Screen readers
- Focus visibility
3. Overengineering Too Early
Don’t build a massive design system on day one.
Start small:
- Variants
- Sizes
- States
Then evolve gradually.
Final Thoughts
A good button component seems simple on the surface, but it becomes foundational as your application grows.
Investing in reusable UI architecture early helps:
- Teams move faster
- Interfaces stay consistent
- Refactoring become safer
- Accessibility remain enforced
The goal isn’t just making buttons.
It’s building reliable systems developers enjoy using.
Further Improvements
You can extend this component with:
- Animation presets
- Icon-only modes
- Tooltip support
- Permission-aware rendering
- Theme support
- Dark mode variants
- Analytics hooks
- Motion transitions
- Slot-based APIs
- Design token integration
Once your component system matures, every new feature becomes dramatically easier to build.