Engineering Notes
Home/Frontend/Ecosystem/Tailwind CSS
Frontend · Ecosystem

Tailwind CSS

Tailwind styles elements with small utility classes generated from a design scale. Read utility-heavy markup, build mobile-first responsive layouts and state variants, compose classes conditionally, and avoid the dynamic-class trap.

🎨 Frontend● Beginner⏱ 7 min read▶ 1 interactive🛠️ 3 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • Utility-first vs component-scoped CSS
  • Spacing, sizing and colour scales
  • Responsive prefixes and state variants
  • Conditional classes, @apply and content scanning

1Utility-First, Not Component-Scoped

Tailwind is a CSS framework built entirely from small, single-purpose utility classes — each one maps to one CSS declaration (or a tightly related group of them). Instead of writing a .card { padding: 1rem; border-radius: 0.5rem; } rule in a separate stylesheet and applying className="card", you compose the same result directly in markup from existing utilities:

tsx
// Traditional component-scoped CSS
// styles.css: .card { padding: 1rem; border-radius: 0.5rem; background: white; box-shadow: ...; }
<div className="card">...</div>

// Tailwind: compose from utilities, no separate stylesheet to maintain
<div className="p-4 rounded-lg bg-white shadow-md">...</div>

The tradeoff is explicit: markup gets more verbose (a long className string), but you never invent new class names, never hunt through a stylesheet to find what .card does, and never worry about a class colliding with an unrelated one elsewhere in the app — the class names themselves already say exactly what they do.

2Spacing, Sizing, and Color Scales

tsx
<div className="mt-4 mb-2 px-6 py-3 w-64 h-32 text-blue-600 bg-gray-100">
  {/* mt-4 = margin-top: 1rem (4 * 0.25rem)
      px-6 = padding-left/right: 1.5rem
      w-64 = width: 16rem
      text-blue-600 / bg-gray-100 = fixed points on Tailwind's color scale */}
</div>

Spacing utilities (m-, p-, w-, h-, gap-, ...) use a consistent numeric scale (Tailwind's default step is 0.25rem per unit, so p-4 = 1rem), and color utilities (text-, bg-, border-, ...) draw from a fixed palette with numbered shades (50 lightest to 950 darkest) per color name — the scale is what keeps an app's spacing/colors visually consistent without a design system being separately maintained and cross-checked by hand.

3Responsive Prefixes

tsx
<div className="text-sm md:text-base lg:text-lg">
  {/* text-sm by default (mobile-first base), text-base at md breakpoint+, text-lg at lg+ */}
</div>
text
sm:  >= 640px
md:  >= 768px
lg:  >= 1024px
xl:  >= 1280px
2xl: >= 1536px

Every utility can be prefixed with a breakpoint name, and unprefixed utilities apply at all sizes as the mobile-first base — a prefixed utility only overrides at that breakpoint and above (min-width media queries under the hood), never below it. There's no separate "mobile stylesheet"; responsiveness is expressed inline, per element, exactly where the rest of that element's styling lives.

4State Variants

tsx
<button className="bg-blue-600 hover:bg-blue-700 focus:ring-2 disabled:opacity-50 disabled:cursor-not-allowed">
  Submit
</button>

hover:, focus:, active:, disabled:, first:, last:, even:/odd:, and many more prefix any utility to apply it only in that state — this compiles to the equivalent CSS pseudo-class/selector (:hover, :disabled, :nth-child(even), ...) rather than requiring any JavaScript state tracking for purely visual state changes.

5Composing Classes Conditionally

tsx
import clsx from "clsx"; // or a project-specific `cn` helper wrapping clsx + tailwind-merge

function Badge({ variant, active }: { variant: "success" | "error"; active: boolean }) {
  return (
    <span
      className={clsx(
        "px-2 py-1 rounded text-xs font-medium",
        variant === "success" ? "bg-green-100 text-green-800" : "bg-red-100 text-red-800",
        active && "ring-2 ring-offset-1"
      )}
    >
      {variant}
    </span>
  );
}

Because a Tailwind className is just a string, conditional styling is plain JavaScript string composition — no CSS-in-JS runtime, no dynamically generated class names. clsx (or classnames) is a small utility for building that string cleanly from a mix of static strings and conditional expressions; tailwind-merge additionally resolves conflicts when two conditional branches supply competing utilities for the same property (e.g. p-2 and p-4 both present — last one shouldn't just get appended, it should replace).

6The @apply Escape Hatch

css
/* styles.css */
.btn-primary {
  @apply px-4 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700;
}

@apply lets a small number of utilities be bundled into one named class inside an actual CSS file — used sparingly, for a handful of patterns repeated so often across unrelated markup that inlining the same long utility string everywhere becomes real duplication (a shared button style used in dozens of places). It's explicitly not meant to become "write component-scoped CSS again for everything," which would undo the traceability benefit of seeing every element's exact styling at its usage site.

7Configuration and Content Scanning

js
// tailwind.config.js
module.exports = {
  content: ["./src/**/*.{js,jsx,ts,tsx}"], // files Tailwind scans for class names
  theme: {
    extend: {
      colors: { brand: "#5b21b6" }, // adds text-brand, bg-brand, etc. alongside the defaults
    },
  },
};

The content array tells Tailwind's build step which files to scan for class name usage — this is what enables production builds to ship only the CSS actually used (see Under the Hood), and theme.extend is how a project layers custom design tokens (brand colors, custom spacing) on top of the default scale without discarding it.

Under the HoodHow it works internally▾

Tailwind generates no CSS speculatively — it scans your actual source files for class name strings and emits only what's found. Unlike a traditional framework that ships a fixed stylesheet with every possible rule pre-defined, Tailwind's build step (a PostCSS plugin) treats every file matched by content as plain text, extracts every substring that looks like a utility class name via pattern matching, and generates CSS rules only for the classes it actually finds used. A project that never writes mt-96 never ships CSS for mt-96, no matter how large Tailwind's theoretical utility set is — this is why the framework can offer such an enormous combinatorial utility surface (every spacing value × every property × every breakpoint × every state) without shipping a correspondingly enormous stylesheet.

This scanning is naive string matching, not JavaScript evaluation — which is why dynamically constructed class names silently produce no CSS. Because the scanner just looks for literal substrings in your source text, className={\text-${size}\} never works as expected: at build time, the scanner sees the literal text ` text-${size} in the file, not the resolved string text-lg that only exists at runtime — so no text-lg rule ever gets generated, and the class silently does nothing when the app runs. The fix is always to write out every possible full class name literally somewhere the scanner can see it (e.g. a lookup object mapping size` to a complete class string, as shown in Common Pitfalls), never to interpolate a partial class name.

Tailwind scans source files as plain text and generates CSS only for literal class names it finds, so dynamically built class names get no CSS
Tailwind scans source files as plain text and generates CSS only for literal class names it finds, so dynamically built class names get no CSS

⚠️ Common Pitfalls

  1. Dynamically constructing class names via string interpolation (` bg-${color}-500 `) — the content scanner can't see the resolved string, so no CSS is generated for it; use a static lookup object mapping known values to complete literal class strings instead.
  2. Reaching for @apply everywhere — turns Tailwind back into hand-maintained CSS files with all the original downsides (a name to invent, a separate file to open to see what it does); reserve it for genuinely repeated, stable patterns.
  3. Manually concatenating conflicting utilities (className={\p-2 ${isLarge ? "p-8" : ""}\}) without a merge-aware helper — both classes end up in the DOM and CSS's normal cascade/specificity rules (usually declaration order) decide the winner, which is fragile; use tailwind-merge/cn to resolve the conflict deterministically instead.
  4. Forgetting the content glob doesn't cover a directory (e.g. a shared UI package outside src/) — classes used there get silently purged from the production build even though they render fine in dev (where Tailwind's JIT watches more broadly), because production scanning only trusts content.
  5. Assuming responsive prefixes are "this breakpoint only" — md:text-lg applies at md and every wider breakpoint unless overridden by a later, more specific prefixed utility (min-width cascading, not range-bound), which trips up anyone expecting max-width-style exclusive ranges.

📋 Quick Reference

text
Spacing:      p-4 / m-2 / px-6 / py-3 / gap-4      (scale: n * 0.25rem by default)
Sizing:       w-64 / h-32 / max-w-md
Color:        text-{color}-{50..950} / bg-{color}-{50..950} / border-{color}-{50..950}
Responsive:   {none}=mobile-base  sm: md: lg: xl: 2xl:  (min-width, cascades upward)
State:        hover: focus: active: disabled: first: last: even: odd:
Composition:  clsx(base, cond && "utility", ...) — or tailwind-merge/cn to resolve conflicts
@apply        → bundle a few utilities into one named class in real CSS (use sparingly)
content: []    in tailwind.config.js → files scanned to decide which utilities ship in prod
Pitfall: no dynamic `` `text-${x}` `` interpolation — scanner needs the literal string present

🧠 Key takeaways

  • Utilities map to a design scale (spacing, colours, type), so UIs stay consistent.
  • Mobile-first: unprefixed classes apply everywhere; sm:/md:/lg: add from a min width.
  • Variants: hover:, focus-visible:, disabled:, dark:, group-hover:…
  • Compose with clsx + tailwind-merge; never build class names dynamically (the scanner can't see them).

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1What is Tailwind CSS's approach?core▾

Utility-first CSS: style elements with small single-purpose classes (flex p-4 text-sm) directly in markup, generated from a design token config. Unused classes are removed at build time, keeping CSS small.

Follow-ups interviewers ask
  • Main benefit? Consistent design tokens and no context switching or naming CSS classes.
Q2What are common criticisms and how are they addressed?core▾

Long class lists and 'ugly' markup: addressed by extracting components, using clsx/cva for variants, and editor tooling; customizing design: via theme config/CSS variables.

Follow-ups interviewers ask
  • When use @apply? Sparingly, e.g. for third-party markup you can't add classes to.
Q3How do you handle variants and conditional classes?core▾

Utilities like clsx and tailwind-merge to combine and resolve conflicting classes, and class-variance-authority (cva) to define variant APIs for components (size, intent).

Follow-ups interviewers ask
  • Why tailwind-merge? p-2 p-4 conflicts need resolution when composing.
Q4How does dark mode and responsive design work?senior▾

Responsive prefixes (md:, lg:) apply styles at breakpoints (mobile-first); dark: variants apply in dark mode via media query or a class strategy.

Follow-ups interviewers ask
  • Mobile-first meaning? Unprefixed styles apply to all sizes; prefixes override upward.
Q5How does Tailwind keep CSS bundles small?core▾

It scans source files for class names and generates only those (JIT engine), so production CSS is typically a few KB to tens of KB.

Follow-ups interviewers ask
  • Pitfall? Dynamically built class names (bg-${color}-500) aren't detected; use full names.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. md:flex means…
Min-width.
2. Why does bg-${color}-500 often fail?
Content scanning.
3. twMerge helps by…
Conflict resolution.
4. @apply should be…
Prefer components.