Tailwind CSS provides a disciplined utility-first design workflow that enforces consistent design tokens and purges unused CSS for minimal production bundles.

This is not a starter tutorial. It assumes you already use Tailwind. The goal is code that still makes sense when a third engineer opens the PR.


Class soup is a process problem, not a CSS problem

"Class soup" looks like this: forty utilities on a single div, three near-copies of the same card in three routes, spacing numbers that drift (p-4 next to p-5 next to px-3.5), and dark variants pasted on every leaf instead of inherited.

Soup usually means one of these:

  1. No shared layout primitives. Every page reinvented max-width, gutters, and vertical rhythm.
  2. No extraction rule. The same button or panel was retyped instead of becoming a component.
  3. Design tokens never left the defaults. Arbitrary values (w-[347px], text-[#3a7]) became normal.
  4. Dark mode was an afterthought. Every element got its own dark: pair instead of a theme surface.

Utilities are fine. Unbounded copy-paste of utilities is not. The fix is structure, not "switch back to BEM."


Layout systems: stop redesigning the page shell

Before components, lock the page chrome. Most products only need a handful of layout pieces:

Primitive Job Typical utilities
Container Max width + horizontal padding mx-auto w-full max-w-6xl px-4 sm:px-6
Stack Vertical rhythm flex flex-col gap-4 (or gap-6 / gap-8 as named variants)
Cluster Horizontal wrap with gap flex flex-wrap items-center gap-2
Grid Responsive columns grid gap-6 md:grid-cols-2 lg:grid-cols-3
Section Vertical section padding py-12 md:py-16

Example in React (same idea in Vue, Svelte, or Angular):

export function Container({
  children,
  className = "",
}: {
  children: React.ReactNode;
  className?: string;
}) {
  return (
    <div className={`mx-auto w-full max-w-6xl px-4 sm:px-6 ${className}`}>
      {children}
    </div>
  );
}

export function Stack({
  children,
  gap = "md",
  className = "",
}: {
  children: React.ReactNode;
  gap?: "sm" | "md" | "lg";
  className?: string;
}) {
  const gaps = { sm: "gap-2", md: "gap-4", lg: "gap-8" } as const;
  return (
    <div className={`flex flex-col ${gaps[gap]} ${className}`}>{children}</div>
  );
}

Rules that hold up:

  • One max-width scale for content (max-w-3xl prose, max-w-6xl app, full-bleed only when intentional).
  • Spacing from a short ladder (2 / 4 / 6 / 8 / 12), not every number on the scale.
  • Gutters that grow once at sm or md, then stay put.
  • Prefer gap over margin-on-children for stacks. Gaps compose; margin collapses and fights components.

A page that is only Container + Stack + a grid of cards is already easier to review than a freehand tree of divs.

Layout with CSS grid and logical structure

For dashboard shells, describe regions once:

<div class="min-h-screen grid grid-cols-1 lg:grid-cols-[16rem_1fr]">
  <aside class="border-r border-slate-200 bg-slate-50 dark:border-slate-800 dark:bg-slate-950">
    <!-- nav -->
  </aside>
  <div class="flex min-h-0 flex-col">
    <header class="border-b border-slate-200 px-4 py-3 dark:border-slate-800">
      <!-- top bar -->
    </header>
    <main class="flex-1 overflow-auto p-4 md:p-6">
      <!-- page content -->
    </main>
  </div>
</div>

Put shell borders and background on the shell. Leave cards and forms to components. Mixing shell chrome into every page is how dark mode and responsive nav break in one route and not another.


Component extraction: when to stop pasting utilities

Extract when two or more places share structure and intent, not when two places happen to share flex. A good extraction has a name a designer would recognize: Button, Card, Field, Badge, Alert, Modal, EmptyState.

Extract the variant API, not a junk drawer of classes

Bad pattern: a Button that accepts className only, and every call site rebuilds the variant:

// Fragile: each call site invents the button again
<button className="rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500">
  Save
</button>

Better pattern: a small variant map (hand-rolled or with cva / tailwind-variants):

const buttonVariants = {
  base: "inline-flex items-center justify-center rounded-lg text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:pointer-events-none disabled:opacity-50",
  variant: {
    primary: "bg-indigo-600 text-white hover:bg-indigo-500",
    secondary: "bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700",
    ghost: "bg-transparent text-slate-700 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800",
  },
  size: {
    sm: "h-8 px-3",
    md: "h-10 px-4",
    lg: "h-11 px-6",
  },
} as const;

type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: keyof typeof buttonVariants.variant;
  size?: keyof typeof buttonVariants.size;
};

export function Button({
  variant = "primary",
  size = "md",
  className = "",
  ...props
}: ButtonProps) {
  return (
    <button
      className={`${buttonVariants.base} ${buttonVariants.variant[variant]} ${buttonVariants.size[size]} ${className}`}
      {...props}
    />
  );
}

Call sites stay short. Design review happens in one file. When brand color changes, you edit the map once.

@apply is a tool, not a lifestyle

In CSS modules or a component layer:

@layer components {
  .card {
    @apply rounded-xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900;
  }
}

Use @apply for stable, repeated surfaces you do not need to parameterize every call. Prefer components with props when variants matter. Avoid turning Tailwind into a second BEM system where every class is a giant @apply dump nobody can override.

The className escape hatch

Allow className on primitives for layout tweaks (className="mt-6 w-full"). Do not allow call sites to re-skin the component by dumping twenty color utilities. If people need a new look, add a variant.


Design tokens: own the scale before it owns you

Defaults are a starting point. Production apps usually need a thin token layer in tailwind.config (v3) or @theme (v4):

// tailwind.config.js (v3 style)
module.exports = {
  theme: {
    extend: {
      colors: {
        brand: {
          50: "#eef2ff",
          500: "#6366f1",
          600: "#4f46e5",
          700: "#4338ca",
        },
        surface: {
          DEFAULT: "#ffffff",
          muted: "#f8fafc",
          inverse: "#0f172a",
        },
      },
      borderRadius: {
        card: "0.75rem",
      },
      boxShadow: {
        card: "0 1px 2px 0 rgb(15 23 42 / 0.06)",
      },
      maxWidth: {
        content: "42rem",
        app: "72rem",
      },
    },
  },
};

Then write bg-brand-600, rounded-card, max-w-app. Arbitrary values should feel slightly embarrassing in review: fine for one-off marketing hero art, wrong for everyday spacing and brand color.

If design ships Figma tokens, map them once into the theme. Do not hand-copy hex codes into JSX forever.


Dark mode without thrash

Pick one strategy and document it:

  1. Class strategy (class on html): best for user toggles and SSR apps that set preference from a cookie or local storage before paint.
  2. Media strategy (prefers-color-scheme): fine for content sites with no toggle.
// v3
module.exports = {
  darkMode: "class",
  // ...
};

Root setup (conceptual):

<html class="h-full antialiased">
  <body class="min-h-full bg-white text-slate-900 dark:bg-slate-950 dark:text-slate-100">
    ...
  </body>
</html>

Theme surfaces beat leaf-by-leaf dark:

Prefer semantic surfaces:

// Surfaces own light/dark; children inherit text color
export function Card({ children, className = "" }: { children: React.ReactNode; className?: string }) {
  return (
    <div
      className={`rounded-card border border-slate-200 bg-white text-slate-900 shadow-card dark:border-slate-800 dark:bg-slate-900 dark:text-slate-100 ${className}`}
    >
      {children}
    </div>
  );
}

Inside the card, prefer text-slate-600 dark:text-slate-300 only where contrast needs a second step. Do not restate background on every nested div.

CSS variables pair well with Tailwind for theme switches that go beyond black/white:

:root {
  --color-bg: 255 255 255;
  --color-fg: 15 23 42;
}
.dark {
  --color-bg: 2 6 23;
  --color-fg: 241 245 249;
}
// theme colors as rgb channels so opacity modifiers work
colors: {
  canvas: "rgb(var(--color-bg) / <alpha-value>)",
  ink: "rgb(var(--color-fg) / <alpha-value>)",
}

Then bg-canvas text-ink flips with the root class. That is how multi-brand or multi-theme apps stay sane.

Avoid flash of wrong theme

If you support a toggle, set the class before first paint (inline script in head, or server-rendered class from cookie). A React useEffect that flips theme after hydration will flash white for dark-mode users. That is a product bug, not a Tailwind bug.

Contrast is part of the API

text-slate-400 on bg-slate-900 may pass your eye and fail WCAG. Check primary text, secondary text, borders, and focus rings in both themes. Put focus styles in the shared variant maps so nobody ships a custom control without a ring.


Habits that keep utilities readable

Group classes with a consistent order

Pick an order and stick to it. A common one:

  1. Layout / display (flex, grid, block)
  2. Position / size (relative, w-full, h-10)
  3. Spacing (p-4, gap-2, m-0)
  4. Typography
  5. Color / background / border
  6. Effects (shadow, transition)
  7. Interactive (hover:, focus-visible:)
  8. Dark / responsive variants last, or grouped with their property

Prettier plugin prettier-plugin-tailwindcss enforces order so review noise drops.

Prefer responsive and state variants on the component, not every leaf

// Prefer
<section className="grid gap-4 md:grid-cols-2">
  <Card />
  <Card />
</section>

// Avoid repeating md: on every card for the same grid job

Split long strings when a component is still local

If a one-off block is long but not worth a shared component yet, break it with a variable or clsx/cn helper:

const panel = cn(
  "rounded-xl border border-slate-200 bg-white p-6",
  "dark:border-slate-800 dark:bg-slate-900",
  emphasized && "ring-2 ring-brand-500",
);

Readable multi-line beats a 300-character attribute.

Do not fight the cascade with ! everywhere

!flex and !p-0 in ten places mean the component API is wrong or a parent is over-styled. Fix ownership of styles. Important modifiers are for third-party escape hatches, not house style.

Content and form defaults

Use @tailwindcss/typography (prose) for long markdown/CMS content instead of hand-styling every h2 and p. Use a shared Field for label + control + error. Forms are where class soup multiplies fastest.


Scanning and CSS size in production

Tailwind only emits classes it finds in content paths. Production pain usually comes from:

  • Dynamic class construction the scanner cannot see:
// Bad: bg-indigo-600 never appears as a full string
const color = "indigo";
return <div className={`bg-${color}-600`} />;
// Good: complete class names in source
const colors = { indigo: "bg-indigo-600", rose: "bg-rose-600" } as const;
return <div className={colors.indigo} />;
  • Missing content globs for monorepo packages (./packages/ui/src/**/*.{ts,tsx}).
  • Huge safelists that reintroduce unused CSS. Prefer complete strings in code over broad safelist regexes.

Keep the design system package inside the scanner's content config. A private UI library that Tailwind never sees is how "missing styles in prod only" happens.


A practical extraction checklist

When a PR looks like soup, walk this list:

  1. Is this page shell or content? Shell goes to layout primitives.
  2. Does a named control already exist? Use it; extend variants if needed.
  3. Are spacing and type from the agreed ladder? Replace one-off values.
  4. Are colors brand tokens or raw palette noise?
  5. Is dark mode handled by a surface, or by 15 dark: pairs on leaves?
  6. Would a two-line cn() make this local block readable without a new file?
  7. Would a shared component save the next three call sites, not just this one?

If you only extract for the third call site, you still win. Premature abstraction of every flex gap-2 is the opposite failure mode.


What good Tailwind looks like six months in

  • Layout primitives (Container, Stack, app shell) are boring and reused.
  • Buttons, fields, cards, and alerts have a short variant API.
  • Theme tokens cover brand, surfaces, and radii; arbitrary values are rare.
  • Dark mode is a root concern; components mostly inherit.
  • Class strings are ordered, often multi-line, and short at call sites.
  • The scanner sees every package that emits class names.

Tailwind does not remove design systems. It makes a lightweight design system cheaper to express. The production pattern is the same as any other UI stack: shared structure, clear variants, tokens over magic numbers, and the discipline to extract when paste starts to lie.