CSS · Architecture

CSS-in-JS vs utility CSS (Tailwind)

Medium ⏱ 45m #architecture

← CSS

TL;DR — CSS-in-JS co-locates styles with components and computes them from props at the cost of a runtime; utility CSS (Tailwind) hands you a fixed vocabulary of atomic classes with zero runtime and a build step that ships only what you use. The real axis isn't "which is prettier" — it's when the styles are resolved: runtime vs build time.


🧠 Mental model

Both approaches are answers to the same two problems: scoping (my .button shouldn't clobber yours) and co-location (styles should live near the thing they style). They just pay for it differently.

CSS-in-JS (runtime) Utility CSS (Tailwind)
Where styles resolve In the browser, per render At build time, once
Scoping mechanism Generated unique class names No scope needed — classes are global but atomic
Dynamic styling Trivial — it's just JS Awkward — you toggle whole classes
Runtime cost Serialize + inject <style> on render None
CSS payload Grows with components Grows with unique utilities, then plateaus

The mental shift: utility CSS bets that most styling is a finite combinatorial vocabulary (a handful of spacings, a type scale, a color ramp), so a fixed atomic set covers 95% of real UI and dedupes hard. CSS-in-JS bets that styling is a function of state and should be expressed in the same language as the state.

⚙️ How it actually works

Tailwind is a PostCSS plugin. At build time it scans your source files as plain text (the content globs), collects every class-looking token, and emits only the CSS for utilities it actually saw. flex, p-4, text-sm are pre-defined rules mapped from your tailwind.config. Because p-4 is emitted once and reused across a thousand components, the stylesheet size is a function of distinct utilities, not component count — it flattens out. The tradeoff moves to HTML: your markup carries the styling weight (class="flex items-center gap-2 px-4 py-2 rounded-md ..."), which gzips well because it repeats.

Runtime CSS-in-JS (styled-components, Emotion) does the opposite. On render it takes your template literal, interpolates props, hashes the result to a stable class name (css-1a2b3c), and injects a <style> rule if that hash isn't already in the sheet. That injection is the cost — it runs during render, competes with hydration, and the library ships to the client. This is why the ecosystem moved toward zero-runtime CSS-in-JS (Linaria, vanilla-extract, and Next.js's server components pushing styled-components to the edge): keep the authoring ergonomics, extract to a static .css file at build time, delete the runtime.

The senior framing: runtime CSS-in-JS trades a per-render tax for the ability to style with the full power of JS. Once you realize most styling doesn't actually need arbitrary JS, that tax looks expensive — which is the whole reason Tailwind and zero-runtime solutions won mindshare.

💻 Code

// Runtime CSS-in-JS (styled-components) — dynamic, but ships a runtime
const Button = styled.button`
  padding: ${(p) => (p.size === 'lg' ? '12px 20px' : '8px 12px')};
  background: ${(p) => p.theme.colors.primary};
  /* interpolated & injected on every render where props change */
`;
// Tailwind — no runtime; variants are class toggles resolved at build time
function Button({ size = 'md', children }) {
  const sizes = {
    md: 'px-3 py-2 text-sm',
    lg: 'px-5 py-3 text-base',
  };
  // ❌ Don't build class strings dynamically — Tailwind's scanner is textual:
  //    `text-${color}-500` will be PURGED because that literal never appears.
  // ✅ Enumerate full class names so the scanner sees them:
  return <button className={`rounded-md bg-blue-600 text-white ${sizes[size]}`}>{children}</button>;
}
// vanilla-extract — CSS-in-JS ergonomics, extracted to a .css.ts at build time
import { style } from '@vanilla-extract/css';
export const button = style({
  padding: '8px 12px',
  background: 'var(--color-primary)', // zero runtime; type-safe; static output
});

⚖️ Trade-offs

  • Reach for utility CSS when you have a design system with a fixed token scale and a team that ships fast — the constraint is the feature, and the zero-runtime story is unbeatable for perf.
  • Reach for CSS-in-JS (ideally zero-runtime) when styles genuinely depend on complex runtime state, when you're shipping a themeable component library, or when co-location and dead-code-elimination-per-component matter more than raw bytes.
  • When NOT to use runtime CSS-in-JS: in React Server Components / streaming SSR. Runtime libraries need a client render to inject styles, causing flash-of-unstyled-content or forcing you to render everything client-side. This is the specific reason the ecosystem is fleeing runtime approaches.
  • Utility CSS's real cost is the HTML, not the CSS — long class lists hurt readability and reviewability. Extract repeated clusters into components (not @apply, which quietly rebuilds the specificity problems Tailwind was avoiding).

💣 Gotchas interviewers probe

  • "Is Tailwind's output huge?" No — that's the misconception. Unpurged dev builds are megabytes; the production build only contains utilities you referenced, so it plateaus regardless of app size. Component-based CSS grows roughly linearly instead.
  • Dynamic class names silently break Tailwind. `p-${n}` won't be generated because purging is a text scan, not evaluation. This trips up nearly everyone once.
  • Runtime CSS-in-JS and SSR: you must collect and inline the critical styles server-side or you ship a FOUC. Candidates who don't mention the SSR style-collection step haven't run it in production.
  • Specificity with @apply re-introduces cascade fights — the thing Tailwind's flat utilities were designed to avoid. Bold rule: prefer a component boundary over @apply.
  • CSS-in-JS defeats browser CSS caching for the runtime chunk — the styles are inside your JS bundle, so a JS change can bust "CSS" caching. Static CSS files cache independently.

🎯 Say this in the interview

"The core distinction is when styles resolve. Tailwind resolves at build time — a PostCSS scan emits only the atomic utilities you actually used, so there's zero runtime and the stylesheet plateaus in size because p-4 is emitted once and shared. Runtime CSS-in-JS resolves per render: it interpolates props, hashes to a class, and injects a <style> tag, which is powerful for state-driven styling but taxes every render and fights SSR and server components. That SSR friction is exactly why the ecosystem moved to zero-runtime CSS-in-JS like vanilla-extract, which keeps the co-location and type safety but extracts static CSS at build time. My default today is utility CSS for product work with a fixed design system, and zero-runtime CSS-in-JS for a themeable component library — I'd avoid runtime CSS-in-JS in anything streaming from the server."

🔗 Go deeper

Primary source — the single best place to go deeper patterns.dev: CSS-in-JS
ESC

Type to search across every question bank.

navigate open 3,610 questions indexed