← Blog/Web Application Development

Building a Production Component Library with Tailwind CSS

How to build a scalable, consistent component library using Tailwind CSS — design tokens, component variants with CVA, accessible patterns, and dark mode.

·8 min read

Tailwind CSS utility classes are fast to write but notoriously easy to misuse. A Tailwind codebase without a component abstraction layer quickly becomes a soup of inconsistent utilities — buttons with 12 different shades of blue, spacing that varies by 4px across screens, and hover states that conflict with each other.

A proper component library built on Tailwind solves this. Here's how we structure it.

Design Tokens as Tailwind Theme Config

The foundation of a consistent UI is a restricted set of design decisions: a colour palette, a type scale, a spacing scale. In Tailwind, these are defined in tailwind.config.ts:

// tailwind.config.ts
import type { Config } from 'tailwindcss'

export default {
  content: ['./app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        brand: {
          50: '#f0fdf4',
          500: '#22c55e',
          600: '#16a34a',
          700: '#15803d',
          900: '#14532d',
        },
        surface: {
          DEFAULT: '#ffffff',
          muted: '#f8fafc',
          subtle: '#f1f5f9',
        },
      },
      fontFamily: {
        display: ['var(--font-syne)', 'system-ui', 'sans-serif'],
        body: ['var(--font-dm-sans)', 'system-ui', 'sans-serif'],
        mono: ['var(--font-dm-mono)', 'monospace'],
      },
      fontSize: {
        '2xs': ['0.625rem', { lineHeight: '1rem' }],
      },
    },
  },
} satisfies Config

Defining brand and surface colours means your components use bg-brand-500 instead of bg-green-500. This single abstraction makes rebranding a theme change, not a find-and-replace across 200 files.

Class Variance Authority (CVA)

CVA is the correct tool for component variants in Tailwind. It generates type-safe variant maps that compose cleanly:

// components/ui/Button.tsx
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'

const buttonVariants = cva(
  // Base classes — always applied
  'inline-flex items-center justify-center gap-2 rounded-lg font-display font-bold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 disabled:pointer-events-none disabled:opacity-50',
  {
    variants: {
      variant: {
        primary: 'bg-brand-600 text-white hover:bg-brand-700 active:bg-brand-800',
        secondary: 'bg-surface-subtle text-slate-900 hover:bg-slate-200 active:bg-slate-300',
        outline: 'border border-slate-200 bg-transparent text-slate-900 hover:bg-surface-subtle',
        ghost: 'bg-transparent text-slate-700 hover:bg-surface-subtle',
        destructive: 'bg-red-600 text-white hover:bg-red-700',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-6 text-base',
        icon: 'h-10 w-10',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
)

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean
}

export function Button({ className, variant, size, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ variant, size }), className)}
      {...props}
    />
  )
}
// lib/utils.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

twMerge handles class conflicts: cn('px-4', 'px-6') correctly produces 'px-6' instead of both.

Accessible Form Components

Form components need more than visual polish — they need semantic correctness and keyboard accessibility:

// components/ui/Input.tsx
import { forwardRef } from 'react'
import { cn } from '@/lib/utils'

interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  label: string
  error?: string
  hint?: string
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, hint, id, className, ...props }, ref) => {
    const inputId = id ?? label.toLowerCase().replace(/\s+/g, '-')
    const errorId = `${inputId}-error`
    const hintId = `${inputId}-hint`

    return (
      <div className="space-y-1.5">
        <label
          htmlFor={inputId}
          className="block font-display font-medium text-sm text-slate-700"
        >
          {label}
          {props.required && (
            <span className="ml-1 text-red-500" aria-hidden>*</span>
          )}
        </label>

        <input
          ref={ref}
          id={inputId}
          aria-describedby={[hint && hintId, error && errorId]
            .filter(Boolean)
            .join(' ')}
          aria-invalid={!!error}
          className={cn(
            'block w-full rounded-lg border px-3 py-2 text-sm transition-colors',
            'placeholder:text-slate-400',
            'focus:outline-none focus:ring-2 focus:ring-brand-500 focus:border-transparent',
            error
              ? 'border-red-400 bg-red-50'
              : 'border-slate-300 bg-white hover:border-slate-400',
            className
          )}
          {...props}
        />

        {hint && !error && (
          <p id={hintId} className="text-xs text-slate-500">{hint}</p>
        )}
        {error && (
          <p id={errorId} role="alert" className="text-xs text-red-600">{error}</p>
        )}
      </div>
    )
  }
)

Input.displayName = 'Input'

Dark Mode

Tailwind's class-based dark mode is the most flexible approach:

// tailwind.config.ts
export default {
  darkMode: 'class', // Adds dark: variant when <html class="dark">
  theme: {
    extend: {
      colors: {
        surface: {
          DEFAULT: 'rgb(var(--color-surface) / <alpha-value>)',
          muted: 'rgb(var(--color-surface-muted) / <alpha-value>)',
        },
      },
    },
  },
}
/* globals.css */
:root {
  --color-surface: 255 255 255;
  --color-surface-muted: 248 250 252;
}

.dark {
  --color-surface: 15 23 42;
  --color-surface-muted: 30 41 59;
}

CSS custom properties that switch values via the dark class mean your components don't need explicit dark: variants for every colour — they inherit the right value automatically.

Component Documentation with Storybook

A component library without documentation becomes a component library nobody uses. Storybook is the standard:

// components/ui/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
import { Button } from './Button'

const meta: Meta<typeof Button> = {
  component: Button,
  tags: ['autodocs'],
}

export default meta

export const Primary: StoryObj<typeof Button> = {
  args: { children: 'Book a call', variant: 'primary' },
}

export const AllVariants: StoryObj<typeof Button> = {
  render: () => (
    <div className="flex gap-4 flex-wrap">
      {(['primary', 'secondary', 'outline', 'ghost', 'destructive'] as const).map(
        (v) => <Button key={v} variant={v}>{v}</Button>
      )}
    </div>
  ),
}

A well-built component library pays for itself quickly on multi-screen products. Consistency across views, faster feature development, and a single place to fix accessibility issues — all from the component layer. If you're starting a new web product, our team builds the component library as part of the initial architecture.