CSS code on a dark screen
Back to Blog
CSSTailwind CSSDesign TokensFrontendArchitecture

CSS Architecture That Doesn't Fall Apart at Scale

Utility classes, component styles, design tokens — making CSS maintainable in large projects requires a deliberate system, not just rules.

Published on January 22, 20258 min read

Why CSS Architecture Matters

CSS doesn't break the way JavaScript does. There's no stack trace when a button looks wrong. Instead, specificity conflicts silently override your styles, global side effects creep in, and six months later nobody wants to touch the stylesheet because changing one thing breaks three others.

Good CSS architecture prevents that.

The Problems to Solve

Before choosing an approach, name the problems:

1. Specificity wars.header .nav .link.active beats .link, and nobody planned for that.

2. Dead code — Styles for components that no longer exist, silently shipped forever.

3. Implicit coupling — Changing .card breaks the sidebar, the modal, and the product page.

4. Naming collisions — Two developers both write .title and one silently wins.

Design Tokens First

Start with design tokens — named values for your design constants:

:root {
  /* Color */
  --color-primary-500: #3b82f6;
  --color-primary-600: #2563eb;
  --color-neutral-900: #111827;

  /* Spacing */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-4: 1rem;

  /* Typography */
  --font-size-sm: 0.875rem;
  --font-weight-semibold: 600;

  /* Radius */
  --radius-md: 0.375rem;
  --radius-full: 9999px;
}

With Tailwind CSS, tokens live in tailwind.config.ts under theme.extend. The point is that your entire UI draws from a single set of values. Change --color-primary-500 once and it propagates everywhere.

Component Isolation with Tailwind

Utility-first CSS (Tailwind) solves specificity wars and dead code naturally:

  • Styles are attached to elements, not class selectors shared across the codebase.
  • Purging removes any class not found in your source at build time — no dead code.
  • Every developer writes the same way.

The challenge is long, messy class strings. The solution is component extraction:

// ❌ Repeated everywhere
<button className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500">

// ✅ Extracted once
function Button({ children, ...props }) {
  return (
    <button
      className="rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 focus:ring-2 focus:ring-blue-500"
      {...props}
    >
      {children}
    </button>
  );
}

You abstract the component, not the class. The Tailwind classes stay readable and explicit inside the component definition.

The `cva` Pattern for Variants

Component variants get messy fast. class-variance-authority (cva) is the cleanest solution I've found:

import { cva } from "class-variance-authority";

const button = cva(
  "rounded-md font-semibold transition-colors focus:outline-none focus:ring-2",
  {
    variants: {
      intent: {
        primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
        secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-400",
        danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
      },
      size: {
        sm: "px-3 py-1.5 text-sm",
        md: "px-4 py-2 text-base",
        lg: "px-6 py-3 text-lg",
      },
    },
    defaultVariants: {
      intent: "primary",
      size: "md",
    },
  }
);

All variant logic is co-located, type-safe, and readable.

Global Styles: Keep Them Minimal

I limit globals.css to:

  • CSS custom property declarations (design tokens)
  • CSS reset/normalize
  • Base typography defaults (body, h1–h6)
  • Third-party library overrides that can't be handled inline

Everything else belongs in components.

The One Rule That Prevents Most Problems

Never write a utility class that sets a visual property by name (e.g., `.blue-text`, `.big-heading`). Name by intent (.text-primary, .heading-xl) or use a framework like Tailwind. When "blue" changes to "teal", intent-named classes still make sense. Color-named classes become lies.