Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 054 / 100

Tailwind CSS patterns that scale: lessons from production

Utility CSS in large codebases without chaos.

Expona Admin8 min read

Three months into building a large Next.js dashboard, I watched our Tailwind CSS file grow from useful to unwieldy. Every new feature meant new color combinations, breakpoint hacks, and developers inventing their own naming conventions. That's when I realized: Tailwind CSS patterns that scale aren't about writing more CSS—they're about writing less of it and making what remains predictable, maintainable, and genuinely reusable across a production codebase.

Components over @apply — Reuse via React components, not giant CSS classes

The first mistake most teams make is treating Tailwind as a replacement for CSS components. They write @apply rules that bundle 12–15 utility classes into a single class name, then share that class across templates. It works at first. Then someone needs a variation. Then another. Soon you're maintaining a CSS file that looks like component logic, but lives outside React where props and composition can't reach it.

The better pattern is obvious in hindsight: if you're reusing UI, make it a React component. A button isn't a CSS class—it's a component that uses classes. Here's what this looks like in practice:

tsx
// ❌ Avoid: CSS class doing component work
// In your Tailwind CSS file
@layer components {
  .btn-primary {
    @apply px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors;
  }
  .btn-secondary {
    @apply px-4 py-2 bg-gray-200 text-gray-800 rounded-lg font-medium hover:bg-gray-300;
  }
}

// In your JSX
<button class="btn-primary">Save</button>

The problem: adding a loading state, icon support, or size variants means editing CSS. Conditional logic lives nowhere.

tsx
// ✅ Prefer: React component with utility classes
export function Button({ 
  variant = "primary", 
  size = "md", 
  isLoading, 
  children, 
  ...props 
}) {
  const baseStyles = "rounded-lg font-medium transition-colors";
  
  const variants = {
    primary: "bg-blue-600 text-white hover:bg-blue-700",
    secondary: "bg-gray-200 text-gray-800 hover:bg-gray-300",
  };
  
  const sizes = {
    sm: "px-2 py-1 text-sm",
    md: "px-4 py-2",
    lg: "px-6 py-3 text-lg",
  };
  
  return (
    <button
      className={`${baseStyles} ${variants[variant]} ${sizes[size]}`}
      disabled={isLoading}
      {...props}
    >
      {isLoading ? "Loading..." : children}
    </button>
  );
}

Now you compose variants in JavaScript. You handle loading states. You can add icons. The component lives where the logic does. Across a codebase of 50+ components, this pattern saves enormous maintenance burden because variations are code, not CSS archaeology.

Design tokens in the theme — Colors, spacing and fonts defined once in CSS variables

Every large codebase I've worked on has the same moment: a designer says "we're shifting to a slightly different shade of blue." You grep your entire Tailwind output and find bg-blue-600 used 47 times across 12 files. Did you catch them all? Should it be bg-blue-700 in the sidebar? Are those instances semantic (primary action) or accidental (oops, wrong color)?

This is why design tokens belong in your tailwind.config.ts, not scattered across component files. Define semantic names once, use them everywhere. When the token changes, everything updates.

tsx
// tailwind.config.ts
export default {
  theme: {
    colors: {
      // Base palette (rarely used directly)
      slate: {
        50: "#f8fafc",
        100: "#f1f5f9",
        // ... rest of palette
      },
      
      // Semantic tokens (used everywhere)
      brand: {
        primary: "var(--color-brand-primary, #2563eb)",
        secondary: "var(--color-brand-secondary, #64748b)",
        success: "var(--color-brand-success, #16a34a)",
        danger: "var(--color-brand-danger, #dc2626)",
      },
      
      surface: {
        default: "var(--color-surface-default, #ffffff)",
        muted: "var(--color-surface-muted, #f1f5f9)",
        overlay: "var(--color-surface-overlay, #000000)",
      },
      
      text: {
        primary: "var(--color-text-primary, #1e293b)",
        secondary: "var(--color-text-secondary, #64748b)",
        tertiary: "var(--color-text-tertiary, #94a3b8)",
      },
    },
    
    spacing: {
      xs: "0.5rem",  // 8px
      sm: "0.75rem", // 12px
      md: "1rem",    // 16px
      lg: "1.5rem",  // 24px
      xl: "2rem",    // 32px
    },
    
    fontFamily: {
      sans: ["Inter", "system-ui", "sans-serif"],
      mono: ["JetBrains Mono", "monospace"],
    },
  },
};

Now your component uses semantic names:

tsx
// Before: confusing, disconnected from intent
<div className="bg-blue-600 text-slate-950 p-4 rounded-lg">

// After: clear semantic intent, single source of truth
<div className="bg-brand-primary text-text-primary p-md rounded-lg">

Bonus: pair this with CSS variables (shown above) so you can implement dark mode or theme switching by updating a single CSS file, not rebuilding Tailwind. In one project, we added a dark mode toggle that changed 30+ component colors by updating three CSS variables. That's leverage.

Consistent ordering — The Prettier plugin sorts classes so diffs stay readable

You've probably seen this in code review: someone adds a utility class to an existing element, and git diff shows the entire className attribute changed, even though only one class was added. Now the diff is unreadable. Did they change anything else? You can't tell.

Install the Tailwind Prettier plugin and let it sort classes automatically:

tsx
// .prettierrc.json
{
  "plugins": ["prettier-plugin-tailwindcss"],
  "tailwindConfig": "./tailwind.config.ts"
}

Now Prettier groups and sorts classes by category: layout, spacing, sizing, colors, transforms, transitions. Every file in your codebase has the same order. Diffs stay clean.

tsx
// ❌ Before: random order, hard to read diffs
<div className="p-4 bg-white rounded-lg text-sm hover:shadow-lg flex gap-2 border border-gray-200 items-center">

// ✅ After: consistent, organized, minimal diffs
<div className="flex items-center gap-2 border border-gray-200 rounded-lg bg-white p-4 text-sm hover:shadow-lg">

When you later add a class, only that class changes in the diff. Reviewers can focus on what actually changed. On a team of 5+ developers, this saves hours of review noise.

Variants with cva — Typed variants for buttons and badges instead of string concatenation

Once you start building reusable components, you need a way to manage variants safely. The naive approach is string concatenation:

tsx
// ❌ Risky: no type safety, easy to typo
const buttonClass = `${baseStyles} ${
  variant === "primary" ? "bg-blue-600" : "bg-gray-200"
} ${
  size === "large" ? "px-6 py-3" : "px-4 py-2"
}`;

// Typo? It just silently renders wrong.
<Button variant="primmary" />

The better tool is cva (Class Variance Authority), which provides type-safe variant composition:

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

const buttonStyles = cva(
  // Base styles applied to all variants
  "rounded-lg font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2",
  {
    variants: {
      variant: {
        primary: "bg-brand-primary text-white hover:bg-blue-700 focus:ring-blue-500",
        secondary: "bg-surface-muted text-text-primary hover:bg-gray-300",
        danger: "bg-brand-danger text-white hover:bg-red-700",
      },
      size: {
        sm: "px-2 py-1 text-sm",
        md: "px-4 py-2",
        lg: "px-6 py-3 text-lg",
      },
      isFullWidth: {
        true: "w-full",
        false: "",
      },
    },
    defaultVariants: {
      variant: "primary",
      size: "md",
      isFullWidth: false,
    },
  }
);

// Type-safe props
type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
  VariantProps<typeof buttonStyles>;

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

// Usage: TypeScript catches typos at compile time
<Button variant="primary" size="lg" /> // ✅ OK
<Button variant="primmary" />          // ❌ Type error

This pattern scales beautifully. You define a component's variants once, and TypeScript ensures they're used correctly everywhere. No runtime surprises. In a codebase with 80+ components, this catches mistakes before they reach production.

Common mistakes

  • Overusing @apply: Every @apply rule is a sign you should write a component instead. If you're sharing styles, share components.
  • Magic numbers in className: If a spacing value, color, or breakpoint doesn't come from your theme config, it's a code smell. Add it to the theme so it's reusable and centralized.
  • Conditional classes with ternary soup: More than two variants? Use cva or a component-level object map. Ternaries become unreadable fast.
  • Ignoring the Prettier plugin: Class ordering looks like a nitpick until you're reviewing diffs where half the file changed because one class was added. Install it on day one.
  • Duplicate tokens: Define colors once in theme.colors. Don't add bg-indigo-400 in one file and bg-purple-500 elsewhere—they're probably meant to be the same semantic color.

Key takeaways

  • Reuse via React components, not @apply. Let props and composition handle variations—they're more powerful than CSS classes.
  • Define design tokens once in tailwind.config.ts, use semantic names everywhere, and pair with CSS variables for runtime theme switching.
  • Install the Tailwind Prettier plugin to maintain consistent class ordering, making diffs readable and reviews faster.
  • Use cva to manage component variants safely. Type safety catches mistakes at compile time, not in production.
  • Ask yourself before writing any Tailwind class: "Is this reused elsewhere?" If yes, it belongs in a component or your theme. If no, it's fine inline—keep things simple.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles