Skip to content
/Tailwind CSS at Scale: Component Patterns That Work
Sep 2023·6 min read

Tailwind CSS at Scale: Component Patterns That Work

How I structure Tailwind CSS in large React projects without creating utility soup — covering cva for variants, design tokens in config, and the abstraction boundary rule that keeps codebases clean.

Tailwind CSSReactCSS ArchitectureDX
Text-to-Speech unsupported
Text Size

Tailwind CSS gets a lot of criticism for producing unmaintainable utility soup in large codebases. After using it across multiple React and Next.js projects — including a digital card SaaS platform serving 10,000+ users with 50+ card templates — I've landed on patterns that keep Tailwind clean, consistent, and refactorable at scale.

The actual problem with Tailwind at scale

The problem isn't Tailwind — it's treating every element as a one-off. Writing className strings directly into every JSX element means the same button appears in 40 files with slightly different class combinations. When the design system changes, you're doing a grep-and-fix across the entire codebase. The fix: treat Tailwind as a design token system, not just CSS shorthand, and enforce abstraction boundaries.

Pattern 1: Component variants with cva

class-variance-authority (cva) is the cleanest solution for managing component variants. Define a variant map once per component, compose with cx() for conditional classes, and call it with props. No ternary className chains, no string interpolation. This is exactly how shadcn/ui works under the hood — it's the community-accepted standard.

Button.tsx with cva variants
import { cva, type VariantProps } from 'class-variance-authority';

const button = cva(
  // base classes always applied
  'inline-flex items-center justify-center rounded-full font-semibold transition-all duration-200 focus:outline-none focus:ring-2',
  {
    variants: {
      variant: {
        primary: 'bg-cyan-400 text-gray-900 hover:bg-white shadow-lg shadow-cyan-400/20',
        outline: 'border border-white/20 text-white hover:border-cyan-400 hover:text-cyan-400',
        ghost:   'text-gray-400 hover:text-white hover:bg-white/5',
      },
      size: {
        sm: 'px-4 py-2 text-sm gap-1.5',
        md: 'px-6 py-3 text-base gap-2',
        lg: 'px-8 py-4 text-lg gap-2.5',
      },
    },
    defaultVariants: {
      variant: 'primary',
      size: 'md',
    },
  }
);

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

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

Pattern 2: Design tokens in tailwind.config

Never hardcode brand colours, font sizes, or spacing values directly in components — always extend tailwind.config.ts. When the brand's primary colour changes, you update one line. When the design team introduces a new spacing scale, it flows through automatically. For the card platform, I extended config with 12 brand colours, 3 font families, and a custom shadow scale that matched the Figma design system exactly.

tailwind.config.ts — design tokens
export default {
  content: ['./src/**/*.{ts,tsx}'],
  theme: {
    extend: {
      colors: {
        brand: {
          primary:   '#3b82f6',
          secondary: '#3b82f6',
          accent:    '#3b82f6',
        },
        surface: {
          DEFAULT: 'rgba(255,255,255,0.04)',
          hover:   'rgba(255,255,255,0.08)',
        },
      },
      fontFamily: {
        display: ['Plus Jakarta Sans', 'sans-serif'],
        mono:    ['JetBrains Mono', 'monospace'],
      },
      boxShadow: {
        glow:     '0 0 24px rgba(59,130,246,0.2)',
        'glow-lg': '0 0 48px rgba(59,130,246,0.15)',
      },
    },
  },
} satisfies Config;

Pattern 3: The abstraction boundary rule

The rule that keeps large Tailwind codebases maintainable: page-level code should rarely touch Tailwind utility classes directly. Pages compose primitive components (Button, Card, Badge, Input, Avatar). Primitive components own their Tailwind strings. Layout components (Section, Container, Grid) own their spacing and responsive classes. This means when a design change comes in, you update one component file — not 30 pages.

Key insight: Tailwind at scale is an architecture problem, not a CSS problem. Build a component library of ~15–20 primitives that encapsulate all the Tailwind strings. Compose them everywhere. Your page-level JSX should read like a design document — component names, not utility strings.

Share X LinkedIn
NY
Neelesh Yadav
React Native Developer with 3 yr 6 mos years building production mobile apps. Writes about performance, architecture, and mobile engineering.
Follow ↗

Comments

Leave a comment

Comments are reviewed before they appear.

More Articles

Boosting React Native Performance: New Architectures Deep Dive
8 min read
Building Real-Time Chat with WebSocket in React Native
6 min read
AR Try-On: Three.js + MediaPipe in React Native
10 min read