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.
// 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 },
})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:
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, notbg-${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
!importantbattles.
Related
Further reading
- vanilla-extract: https://vanilla-extract.style/
- Emotion docs: https://emotion.sh/docs/introduction
tailwind-merge: https://github.com/dcastil/tailwind-merge- Next.js — CSS / styling: https://nextjs.org/docs/app/building-your-application/styling
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?” -
clsxfor the conditions andtailwind-mergearound it, usually as onecnhelper. Without the merge,px-2andpx-4both 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.