Skip to content
Back to snippets
reacttypescripttailwindcssdesign-systemfrontend

Building a Production-Ready Button System with React, TypeScript, and Tailwind CSS

5 min read752 views

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.ts

Installing Dependencies

We’ll use two tiny libraries for conditional class management.

npm install clsx tailwind-merge

Creating 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.