CSS · Architecture
CSS-in-JS vs utility CSS (Tailwind)
⚡ 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
classlists 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
@applyre-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-4is 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
- patterns.dev — CSS-in-JS — the pattern, its variants, and where the runtime cost lives.
- Tailwind — Optimizing for production — how the content scan and purge actually work.
- vanilla-extract — the zero-runtime, type-safe CSS-in-JS model.
- Why we're breaking up with CSS-in-JS — the Emotion maintainer-adjacent case against runtime cost.