frontend / component libraries / styling_approaches.md

Styling Approaches

4 min read source

Styling Approaches

TL;DR

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.

Interview Q&A

Q: Compare the styling approaches.

A:

Approach Runtime cost RSC-friendly DX Examples
Plain CSS / Sass none yes global scope risk .css, .scss
CSS Modules none (build-time) yes scoped, familiar *.module.css
Utility-first none (build-time) yes 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) yes CSS-in-JS ergonomics, no runtime vanilla-extract, Linaria, Panda, Pigment

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

A: 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 ../19_rendering_modes/ and ../15_performance/.

Q: What is Emotion, and what does it power?

A: 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 MaterialUI/core_principles.md.

Q: Why zero-runtime CSS-in-JS?

A: 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.

Q: How do you compose conditional class names with Tailwind?

A: 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));

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

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/utility_classes.md.

Q: How do you decide which to use?

A: 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 / edge cases

  • 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 (../15_performance/).
  • Specificity wars with global CSS — Modules/utilities largely avoid this; plain CSS at scale invites !important battles.

What a senior is expected to say

  • “Runtime CSS-in-JS (Emotion/styled-components — Emotion powers MUI) injects styles at render: a perf cost and an RSC problem, since Server Components can’t run it. New apps lean CSS Modules, Tailwind, or zero-runtime libs.”
  • “Zero-runtime CSS-in-JS (vanilla-extract, Panda, Pigment) keeps the DX, extracts CSS at build — RSC-safe.”
  • cn = twMerge(clsx(...)) for conditional Tailwind classes — tailwind-merge makes overrides actually win.”
  • “I pick on RSC needs, dynamism, and perf budget — not dogma.”

Cross-references

Further reading