Topic #07Core18 min read

Tailwind CSS

Utility-first styling from first principles — why atomic classes exist, how the JIT compiler produces tiny bundles, the config-driven token system, responsive and state modifiers, dark mode, extraction with @apply and components, and the gotchas that bite in production.

#tailwind#css#utility-first#design-tokens#jit#responsive#dark-mode

What 'utility-first' actually means. Traditional CSS gives each component a semantic class (.card, .btn-primary) and you write rules for it in a stylesheet. Tailwind inverts this: it provides thousands of tiny single-purpose (atomic) classesp-4 sets padding, flex sets display:flex, text-center sets text-align:center — and you compose them directly in markup. You almost never write custom CSS; you assemble the styling from a fixed vocabulary. The mental shift is from 'name a thing, then style the name' to 'describe the styling inline from a design-system palette.'

Why atomic classes solve the scaling problems of CSS. Because each utility does exactly one thing and means the same thing everywhere, there are no naming decisions, no scoping collisions, and no specificity wars — every utility has the same low specificity. Crucially, the CSS stops growing: adding a hundred new components reuses the same handful of utilities rather than adding a hundred new rules. This is why large Tailwind projects have small, flat stylesheets while large hand-written CSS codebases tend to grow forever and accumulate dead rules nobody dares delete.

The JIT compiler and why bundles stay tiny. Tailwind could theoretically generate millions of utility combinations, so it doesn't ship them all. The Just-In-Time compiler scans your source files (templates, JSX, etc.), finds the class names you actually use as literal strings, and generates only those rules into the output CSS. Unused utilities never exist in production. This means the shipped CSS size is proportional to the distinct utilities you use — typically a few kilobytes — not to Tailwind's full capability, and arbitrary values like w-[137px] are generated on demand.

The config file is your design system. tailwind.config.js defines the theme — the finite scales of colors, spacing, font sizes, breakpoints, radii, and shadows that all utilities draw from. p-4 isn't 'some padding'; it's specifically 1rem because the spacing scale says so. Centralising these tokens is what makes a Tailwind product visually coherent: everyone picks from the same palette, so spacing and color stay on-system automatically. You extend rather than replace defaults via theme.extend, which adds brand tokens (colors.brand) while keeping Tailwind's sensible base scales.

Modifiers: responsive, state, and beyond. Utilities take prefixes that scope when they apply. Responsive prefixes are mobile-first min-width breakpoints: an unprefixed utility applies at all sizes, md: applies from the medium breakpoint up, lg: from large up. So grid-cols-1 md:grid-cols-2 lg:grid-cols-3 is a responsive grid written inline. State prefixes cover interaction and structure: hover:, focus:, active:, disabled:, focus-visible:, first:, last:, odd:, and the group/peer variants (group-hover:) that let a parent's state style a child. Modifiers stack: md:hover:bg-blue-800.

Mobile-first is a deliberate constraint. Tailwind's breakpoints are min-width, so you style the smallest screen with unprefixed utilities and layer on changes for larger screens with prefixes. This nudges you toward designing mobile-up, which is almost always the right default. A common beginner mistake is trying to use md: to mean 'only on medium' — it actually means 'medium and above,' so you scope the base case first and override upward.

Dark mode and theming. Tailwind's dark: variant applies utilities when dark mode is active — bg-white dark:bg-slate-900. You configure whether dark mode follows the OS (media strategy) or a class you toggle on <html> (class strategy, better for a user preference switch). Combined with CSS custom properties in the theme, this gives full theming without leaving markup. Arbitrary properties and CSS variables (bg-[var(--brand)]) bridge Tailwind to a design-token layer shared with the rest of the app.

Extraction: @apply, component classes, and the DRY question. Repeating a long utility list across many buttons is the classic complaint. Three answers: (1) extract a component in your framework (<Button>), which is the preferred fix because it colocates markup and styling and reuses through composition; (2) use @apply in a CSS file to fold utilities into a semantic class (.btn { @apply px-4 py-2 rounded bg-blue-700; }) — handy but use sparingly, since overusing it recreates the exact global-CSS problems Tailwind removed; (3) plugins for cross-cutting concerns. Prefer component extraction over @apply.

The cn/clsx pattern for conditional classes. In components you build class strings conditionally, and the safe way is a helper that merges class lists — clsx for conditionals and tailwind-merge to resolve conflicts (so a later px-6 beats an earlier px-4). Never build utility names by string concatenation (`bg-${color}-500`): the JIT scanner only detects complete literal class strings, so concatenated names get purged from production and silently vanish. Use full class names in a lookup map instead.

Where the layers live: base, components, utilities. Tailwind organises generated CSS into three layers via @layerbase (resets and element defaults), components (your extracted component classes), and utilities (the atomic classes). Layer order controls the cascade, so your utilities correctly override component defaults, and your components override base. Understanding layers explains why a utility placed in markup reliably wins over a component class.

Common gotchas. (1) Dynamically constructed class names get purged — always write complete literals. (2) Long class lists hurt readability; extract components early. (3) @apply overuse resurrects global CSS problems. (4) Specificity conflicts when mixing with other CSS — use tailwind-merge. (5) Content configuration must include every file that contains classes, or the JIT won't see them and they'll be missing in prod. (6) Arbitrary values (top-[117px]) are an escape hatch, not a habit — too many defeats the design-system consistency that is the whole point.

The mental model (memorise this). Tailwind = compose a fixed vocabulary of atomic classes in markup; the JIT compiler ships only the ones you literally write, so bundles stay tiny; the config is your design system and the source of every value; modifiers (md:, hover:, dark:) express responsiveness, state, and theming inline, mobile-first; extract repetition into framework components, not @apply; and never build class names dynamically.

Backend Analogy

Tailwind is like building services from a curated library of small, well-tested utility functions instead of writing bespoke logic each time. The atomic classes are those reusable helpers — each does one thing, is used everywhere, and never conflicts, exactly like pure static utility methods. `tailwind.config.js` is your central `application.yml` / constants module: the single source of truth for the values (spacing scale, palette) every 'call site' references, so the whole system stays consistent. The JIT compiler is tree-shaking / dead-code elimination at the CSS layer — only the code paths you actually invoke make it into the artifact, so the binary stays small no matter how large the library. `@apply` is like extracting a private helper method to avoid repetition — useful in moderation, but if you wrap everything you're back to the tangled bespoke code you were trying to avoid. And building class names by string concatenation is the equivalent of reflection-based lookups the tree-shaker can't see: the optimiser strips what it can't prove is used, and it breaks in production.

Key Insights
  • Utility-first inverts CSS: instead of naming a component and styling the name, you compose single-purpose atomic classes directly in markup from a fixed design-system vocabulary.
  • Atomic classes eliminate naming decisions, scoping collisions, and specificity wars, and the stylesheet stops growing because new components reuse the same utilities.
  • The JIT compiler scans source for literal class names and generates only those rules, so production CSS is proportional to the distinct utilities you use — usually a few kilobytes.
  • tailwind.config.js defines the theme — the finite scales of colors, spacing, type, and breakpoints — which is what makes a Tailwind product visually consistent; extend, don't replace, defaults.
  • Modifiers scope when a utility applies: responsive prefixes (md:, lg:) are mobile-first min-width, state prefixes (hover:, focus:, disabled:) cover interaction, and they stack (md:hover:).
  • dark: applies utilities in dark mode; the class strategy (toggle a class on <html>) is best for a user-controlled theme switch.
  • Prefer extracting a framework component over @apply for repetition; overusing @apply resurrects the global-CSS problems Tailwind removed.
  • Never build class names by concatenation — the JIT scanner only detects complete literal strings, so dynamic names get purged and silently break in production.
  • Use clsx for conditional classes and tailwind-merge to resolve conflicting utilities predictably.
  • Arbitrary values (w-[137px], bg-[var(--brand)]) are an escape hatch — occasional use is fine, but too many defeats the design-system consistency that is the point.

Worked Code

Utilities, responsive + state modifiers, dark mode
HTML
<!-- A button: base styles, hover/focus state, dark-mode variant -->
<button class="px-4 py-2 rounded-md bg-blue-700 text-white
               hover:bg-blue-800 focus-visible:ring-2 focus-visible:ring-blue-300
               disabled:opacity-50 dark:bg-blue-500 dark:hover:bg-blue-400
               transition duration-150">
  Submit Expense
</button>

<!-- Responsive grid: 1 col on mobile, 2 from md up, 3 from lg up (mobile-first) -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
  <div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4">Card 1</div>
  <div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4">Card 2</div>
  <div class="bg-white dark:bg-slate-800 rounded-lg shadow p-4">Card 3</div>
</div>

<!-- group/peer: parent hover styles a child -->
<a href="#" class="group block p-4 rounded hover:bg-slate-100">
  <span class="text-slate-500 group-hover:text-blue-700">Reveal on hover</span>
</a>
tailwind.config.js — extend the theme with brand tokens
JavaScript
/** @type {import('tailwindcss').Config} */
module.exports = {
  // content MUST list every file with classes, or the JIT won't emit them
  content: ["./src/**/*.{js,ts,jsx,tsx,html}"],
  darkMode: "class", // toggle a .dark class on <html> for a user preference switch
  theme: {
    extend: {
      // Add brand colors WITHOUT losing Tailwind's default palette
      colors: {
        brand: { 500: "#1B4F72", 600: "#154360" },
      },
      // Bridge to a shared design-token layer via CSS variables
      backgroundColor: { token: "var(--color-brand)" },
      borderRadius: { xl2: "1rem" },
    },
  },
  plugins: [],
};
Component extraction vs @apply (prefer the component)
TSX
// PREFERRED: extract a framework component — colocated, composable, typed.
import clsx from "clsx";
import { twMerge } from "tailwind-merge";

function Button({ variant = "primary", className, ...props }:
  { variant?: "primary" | "danger"; className?: string } & React.ComponentProps<"button">) {
  return (
    <button
      className={twMerge(clsx(
        "px-4 py-2 rounded-md font-medium text-white",
        variant === "primary" && "bg-blue-700 hover:bg-blue-800",
        variant === "danger" && "bg-red-600 hover:bg-red-700",
        className, // callers can override; twMerge resolves conflicts
      ))}
      {...props}
    />
  );
}

/* ALTERNATIVE (use sparingly): @apply in a CSS file
   .btn { @apply px-4 py-2 rounded-md font-medium text-white bg-blue-700; }
   Overusing @apply recreates the global-CSS problems Tailwind removed. */
The dynamic-class trap — and the correct fix
TSX
// WRONG: the JIT scanner cannot see this — it gets purged in production.
function BadBadge({ color }: { color: "red" | "green" }) {
  return <span className={`bg-${color}-500`}>x</span>; // vanishes in prod!
}

// RIGHT: map to COMPLETE literal class strings the scanner can detect.
const BADGE: Record<"red" | "green", string> = {
  red: "bg-red-500",
  green: "bg-green-500",
};
function GoodBadge({ color }: { color: "red" | "green" }) {
  return <span className={BADGE[color]}>x</span>; // full literals — safe
}
Directives & layers in your entry CSS
CSS
/* app.css — Tailwind injects generated rules into three ordered layers */
@tailwind base;        /* resets + sensible element defaults */
@tailwind components;   /* your extracted component classes live here */
@tailwind utilities;    /* atomic utilities — win the cascade over components */

/* Add your own tokens/base styles into the right layer */
@layer base {
  :root { --color-brand: #1B4F72; }
  body { @apply text-slate-800 dark:text-slate-100; }
}

Try It Live

Edit the code and press Run — it executes safely in a sandboxed iframe. Use the Console tab for log output.

What the JIT generates: Tailwind utilities as their plain-CSS equivalents

Interview-Ready Q&A

The Just-In-Time compiler scans your source files for class names that appear as literal strings and generates only those rules into the output. Unused utilities never exist in production, so the shipped CSS is proportional to the distinct utilities you actually use — typically a few kilobytes — rather than to Tailwind's full capability. Arbitrary values are generated on demand too.

Things to Remember
  • 1Utility-first: compose atomic classes in markup instead of writing custom CSS.
  • 2JIT scans source for literal class names and ships only those — tiny production bundles.
  • 3tailwind.config.js is your design system; use theme.extend to add tokens without losing defaults.
  • 4Responsive prefixes (md:, lg:) are mobile-first min-width; state prefixes (hover:, focus:, disabled:) stack.
  • 5dark: for dark mode; the class strategy suits a user-controlled theme toggle.
  • 6Prefer extracting a component over @apply; overusing @apply resurrects global-CSS problems.
  • 7Never concatenate class names — the scanner needs complete literals or they get purged.
  • 8Use clsx for conditionals and tailwind-merge for conflicting utilities.
  • 9content config must include every file with classes, or utilities go missing in prod.
  • 10Arbitrary values are an escape hatch — keep them rare to preserve design-system consistency.

References & Further Reading