Frontend / Component libraries / 04_styling_approaches.md

Styling Approaches

Updated 4 interview angles 5 min read source
On this page10
  1. Compare the styling approaches.
  2. What’s the runtime cost of CSS-in-JS, and the RSC problem?
  3. What is Emotion, and what does it power?
  4. Why zero-runtime CSS-in-JS?
  5. How do you compose conditional class names with Tailwind?
  6. How do you decide which to use?
  7. Gotchas
  8. Related
  9. Further reading
  10. Interview angle

Styling Approaches

Five families: CSS Modules (scoped CSS files), utility-first (Tailwind), runtime CSS-in-JS (Emotion, styled-components), zero-runtime CSS-in-JS (vanilla-extract, Linaria, Panda, Pigment CSS), and plain CSS/Sass. The senior-relevant shifts: runtime CSS-in-JS has a performance cost and is awkward with React Server Components, which has pushed the ecosystem toward zero-runtime and utility-first. Emotion is the engine behind MUI. For conditional classes, clsx + tailwind-merge (the cn helper) is the standard combo.

Compare the styling approaches.

Approach Runtime cost, RSC DX, examples
Plain CSS, Sass none; works global scope risk; .css, .scss
CSS Modules none, build-time; works scoped, familiar; *.module.css
Utility-first none, build-time; works fast, co-located; Tailwind
Runtime CSS-in-JS serialize + inject on render; problematic dynamic, co-located; Emotion, styled-components
Zero-runtime CSS-in-JS none, extracted at build; works same ergonomics; vanilla-extract, Linaria, Panda, Pigment

What’s the runtime cost of CSS-in-JS, and the RSC problem?

Runtime CSS-in-JS (Emotion/styled-components) serializes styles to CSS strings and injects <style> tags during render, adding main-thread work proportional to component count — measurable on large pages (hurts INP/hydration). The RSC issue: these libraries rely on React context and runtime style injection, which Server Components don’t run — so styled-components/Emotion need a "use client" boundary and special SSR setup in the App Router. That friction is why Next.js docs steer toward CSS Modules, Tailwind, or zero-runtime libraries. See Rendering Modes — Senior Interview Prep and Frontend Performance — Senior Interview Prep.

What is Emotion, and what does it power?

Emotion is a runtime CSS-in-JS library: css prop / styled API, theme via context, generates hashed class names and injects styles. MUI (v5+) is built on Emotion (its sx prop and styled go through Emotion’s engine). Knowing this explains MUI’s SSR setup (CacheProvider/createCache) and why MUI inherits CSS-in-JS’s RSC caveats. See Material-UI Core Principles.

Why zero-runtime CSS-in-JS?

It keeps the authoring ergonomics (co-located, typed styles, theming) but extracts the CSS to static files at build time — no runtime serialization, RSC-compatible. vanilla-extract (typed .css.ts), Linaria, Panda, and Pigment CSS (the styled engine MUI is moving toward) are the players. This is the direction the ecosystem is heading for new apps that need CSS-in-JS-like DX without the runtime tax.

ts
// button.css.ts — vanilla-extract. Typed, co-located, and gone
// by the time the browser sees it.
import { style } from '@vanilla-extract/css'
import { vars } from './theme.css'

export const button = style({
  background: vars.color.brand,
  padding: vars.space.md,
  ':hover': { background: vars.color.brandHover },
})
tsx
import { button } from './button.css'

export const Button = (p) => <button className={button} {...p} />

The .css.ts file runs at build time and emits a plain stylesheet; the component ships a static class name. That is why it works in a Server Component — there is no runtime, no context, and no style injection during render.

How do you compose conditional class names with Tailwind?

clsx builds the class string from conditions; tailwind-merge resolves conflicting Tailwind classes (last wins), so an override like p-2 actually beats an earlier p-4:

ts
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export const cn = (...inputs) => twMerge(clsx(inputs));

// className overrides win correctly
cn("px-2 py-1", isActive && "bg-blue-600", className);

The cn helper is the de facto standard (shadcn/ui ships it). Without tailwind-merge, conflicting utilities both end up in the class list and the cascade order (not your intent) decides — a subtle bug. See Tailwind CSS Utility Classes.

How do you decide which to use?

Default to CSS Modules or Tailwind for new App Router / RSC apps (no runtime, RSC-safe). Use runtime CSS-in-JS when you’re already invested (existing MUI/styled-components app) and accept the client-boundary cost. Choose zero-runtime CSS-in-JS when you want typed, co-located styles without the runtime hit. There’s no universal winner — pick on RSC needs, dynamism, team familiarity, and perf budget.

Gotchas

  • Runtime CSS-in-JS + SSR streaming needs careful style flushing or you get FOUC / hydration mismatches.
  • styled-components/Emotion in Next App Router must be in client components with the registry setup; server components can’t use them.
  • Tailwind class merging — without tailwind-merge, “override” classes silently don’t override; with arbitrary values, watch the JIT scanning your dynamic strings (it only sees complete class literals, not bg-${color}).
  • Critical CSS — extracting above-the-fold CSS speeds first paint (LCP); frameworks/CSS Modules handle this better than runtime injection (Frontend Performance — Senior Interview Prep).
  • Specificity wars with global CSS — Modules/utilities largely avoid this; plain CSS at scale invites !important battles.

Further reading

Interview angle 4

  • “What is wrong with runtime CSS-in-JS now?” - two things. It serialises and injects styles during render, which is real main-thread work on every render, and it cannot run in a Server Component at all. That second point is what actually moved the ecosystem: a library requiring "use client" pulls your whole tree across the boundary.
  • “So what do new projects use?” - CSS Modules, Tailwind, or a zero-runtime CSS-in-JS library. The last group - vanilla-extract, Panda, Pigment - keeps the authoring experience and extracts real CSS at build time, so it is RSC-safe with no runtime cost.
  • “How do you handle conditional Tailwind classes?” - clsx for the conditions and tailwind-merge around it, usually as one cn helper. Without the merge, px-2 and px-4 both end up in the class list and the winner is whichever CSS rule came later - so the override you wrote does not reliably win.
  • “How would you choose?” - on whether you need Server Components, how dynamic the styling genuinely is, and what performance budget you have. Most “dynamic” styling is a handful of variants that a static approach handles fine, and it is worth checking that before paying a runtime cost for it.