Skip to content
C Codeloom
Tailwind

Tailwind Design System Patterns That Scale

Build a design system on top of Tailwind that stays consistent as the app grows. Tokens, components, variants, and the cva pattern explained.

·5 min read · By Codeloom
Intermediate 10 min read

What you'll learn

  • Why utility-first does not mean utility-only
  • How to define tokens in the Tailwind theme
  • How to wrap components instead of repeating classes
  • How to manage variants with cva
  • How to keep your design system from drifting

Prerequisites

  • A project using Tailwind CSS

What and Why

Tailwind makes it easy to ship a UI fast: stack utilities and you have a button. But a year and a hundred buttons later, are they all the same color? Same radius? Same focus ring? Without a system, utilities encourage the same drift that custom CSS did, just with shorter class names.

A design system on top of Tailwind brings back consistency without losing the speed. The pattern: tokens for primitives, components for composition, variants for options, conventions for the edges.

Mental Model

tokens (colors, spacing, radii)         -> tailwind.config theme
 |
 v
primitive utilities (px-4, bg-brand)    -> generated CSS
 |
 v
component wrappers (Button, Card)       -> JSX/Astro
 |
 v
pages and features                      -> compose components
Layers of a Tailwind design system

Tokens live in one place. Components hide the class list so callers do not need to remember it. Variants encode the legal combinations.

Hands-on Example

Define tokens in the config:

// tailwind.config.js
export default {
  content: ['./src/**/*.{astro,tsx,html}'],
  theme: {
    extend: {
      colors: {
        brand: {
          50:  '#eff6ff',
          500: '#3b82f6',
          600: '#2563eb',
          700: '#1d4ed8',
        },
        surface: '#ffffff',
        ink: '#0f172a',
      },
      borderRadius: { lg2: '14px' },
      boxShadow: { card: '0 1px 2px rgb(0 0 0 / 0.06), 0 4px 12px rgb(0 0 0 / 0.06)' },
      fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'] },
    },
  },
};

Now utilities like bg-brand-600, rounded-lg2, and shadow-card describe your system, not arbitrary values.

Wrap recurring patterns in components. A Card in React:

export function Card({ children, className = '' }) {
  return (
    <div className={`bg-surface rounded-lg2 shadow-card p-4 ${className}`}>
      {children}
    </div>
  );
}

Callers write <Card>...</Card> instead of memorizing the utility stack. Pass className for one-off tweaks without rewriting the base.

For components with variants, the class-variance-authority (cva) library is the de facto standard:

import { cva, type VariantProps } from 'class-variance-authority';

const button = cva(
  'inline-flex items-center justify-center font-medium rounded-lg2 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500',
  {
    variants: {
      intent: {
        primary:   'bg-brand-600 text-white hover:bg-brand-700',
        secondary: 'bg-brand-50 text-brand-700 hover:bg-brand-100',
        ghost:     'bg-transparent text-ink hover:bg-slate-100',
      },
      size: {
        sm: 'h-8 px-3 text-sm',
        md: 'h-10 px-4 text-sm',
        lg: 'h-12 px-5 text-base',
      },
    },
    defaultVariants: { intent: 'primary', size: 'md' },
  }
);

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof button>;

export function Button({ intent, size, className, ...rest }: ButtonProps) {
  return <button className={button({ intent, size, className })} {...rest} />;
}

Now <Button intent="ghost" size="sm">Cancel</Button> is the only thing callers write, and the system guarantees that only allowed combinations exist.

For the few places you still want a CSS rule (a complex gradient, a custom focus ring), use @layer components so the rule lives next to your tokens:

@layer components {
  .ring-brand {
    box-shadow: 0 0 0 3px theme('colors.brand.500' / 30%);
  }
}

Common Pitfalls

Endless <div className="..."> strings. A button with twenty utilities pasted in three components is three bugs in waiting. Wrap it.

Hard-coded hex colors in templates. Every bg-[#2563eb] is a future rebrand bomb. Add a token and use bg-brand-600.

Variants implemented with conditionals in JSX:

<button className={`px-4 ${primary ? 'bg-blue-500' : 'bg-gray-200'}`}>

For two states, fine. For four states and three sizes, the matrix explodes. Use cva.

Inconsistent focus styles. Accessibility lives or dies on focus rings. Encode them once in your button base classes, not per-call.

Skipping the type system. Without TypeScript variants, <Button intent="primery"> silently renders nothing. cva plus VariantProps catches the typo at compile time.

Practical Tips

Keep the token list short. Five neutrals, five brand shades, four sizes, three radii cover 90% of UI. Less is more.

Document tokens visually. A /styleguide route that shows every color, spacing scale, and component variant is the cheapest design review you will ever ship.

Use CSS variables for theming if you support light and dark or per-tenant brands. Reference them from the Tailwind theme:

colors: { brand: 'rgb(var(--brand) / <alpha-value>)' }

Set --brand per theme and the whole system follows.

Lint for forbidden classes. ESLint plus eslint-plugin-tailwindcss can flag arbitrary values and unknown classes before review.

Reach for @apply sparingly. It is fine inside @layer components for true reusable rules, but using it to “fix” every utility line erodes the benefits of Tailwind.

Wrap-up

Tailwind gives you speed; a design system gives you consistency. Pull values into the theme, wrap repeated patterns in components, encode variants with cva, and add a tiny @layer components for the truly custom CSS. The result is an app you can extend for years without losing the original design.