Topic 11.4
CSS-in-JS
In one line
CSS-in-JS libraries such as styled-components and Emotion let you write CSS inside JavaScript components and compute it from props; runtime versions generate and inject styles in the browser on every render, which costs time and clashes with Server Components, so newer zero-runtime tools extract the CSS at build time.
Think of it like this
A tailor who sews your clothes in the shop while you wait, versus a shop with clothes already on the racks. The tailor (runtime CSS-in-JS) can make anything to your exact size, but you wait every time, and the tailor must be in the shop. The ready-made rack (a stylesheet built ahead of time) is instant. Zero-runtime CSS-in-JS is a tailor who works the night before: you still order with exact measurements, but the clothes are ready when the shop opens.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- CSS-in-JS
- Writing CSS inside JavaScript, usually attached to components, with a library generating the real CSS.
- Tagged template literal
- A template string written right after a function name, like
styled.buttonfollowed by the string. The function receives the text parts and the values. - Runtime
- While the app is running in the browser, as opposed to build time on the developer's machine.
- useInsertionEffect
- A React 18 hook that runs before layout effects. Made for libraries that insert
<style>tags. - Transient prop
- A prop starting with
$(styled-components) used for styling only and not passed to the DOM. - ThemeProvider
- A context provider that gives every styled component access to shared design values.
- Style registry
- Server-side code that collects the CSS generated while rendering and puts it in the HTML.
- Zero-runtime
- A CSS-in-JS tool that produces a normal CSS file at build time, so no styling code runs in the browser.
Step by step
01Write a styled component
In styled-components, styled.button is a function you call with a tagged template. Functions inside ${...} receive the component's props. Props starting with $ are transient: used for styling and not forwarded to the DOM.
import styled from "styled-components";
export const Button = styled.button`
padding: 8px 16px;
border-radius: 8px;
background: ${(p) => (p.$primary ? p.theme.colors.primary : "white")};
&:hover { filter: brightness(1.1); }
`;
// <Button $primary onClick={save}>Save</Button>02Render: CSS text from props, a hash, a class
Each render runs the functions with the current props and theme, producing the final CSS text. The library hashes it to a class name. Same CSS text, same class; different text, new class.
03Insert: a rule in a <style> tag before layout effects
If the class is new, the library adds the rule to a <style> tag in <head>. It does this in useInsertionEffect, so by the time your useLayoutEffect measures the element, its styles already apply.
04Server rendering needs a registry
On the server there is no <head> to append to. The library collects every rule generated during the render and the framework inserts them into the HTML stream. In the Next.js App Router that wrapper must be a Client Component.
"use client";
import { useState } from "react";
import { useServerInsertedHTML } from "next/navigation";
import { ServerStyleSheet, StyleSheetManager } from "styled-components";
export default function StyledComponentsRegistry({ children }) {
const [sheet] = useState(() => new ServerStyleSheet());
useServerInsertedHTML(() => {
const styles = sheet.getStyleElement();
sheet.instance.clearTag();
return <>{styles}</>;
});
if (typeof window !== "undefined") return <>{children}</>;
return <StyleSheetManager sheet={sheet.instance}>{children}</StyleSheetManager>;
}05Zero-runtime: the same idea at build time
vanilla-extract runs .css.ts files during the build. style() returns a class-name string and the CSS goes into a static file, so the component can be a Server Component.
import { style, styleVariants } from "@vanilla-extract/css";
const base = style({ padding: "8px 16px", borderRadius: 8 });
export const button = styleVariants({
primary: [base, { background: "teal", color: "white" }],
ghost: [base, { background: "transparent" }],
});
// Button.tsx
// import { button } from "./button.css";
// <button className={button.primary}>Save</button>Try it yourself
- 1
Give Help its own look
In the first example, add a
$dangerprop to Help and a lineborder-color: ${(p) => (p.$danger ? "#e03131" : "#0b7285")};. Predict how many<style>tags you get now. (Three: Help's CSS text is now different from Cancel's.) - 2
Switch the insertion effect for a normal effect
Change
useInsertionEffecttouseEffectin the first example. Predict what the layout effect logs for the padding. (It runs before the style exists, so it sees the browser's default padding.) - 3
Count the rules by hand
In the second example, drag the slider slowly across its whole range, then run
document.querySelectorAll("style[data-mini]").lengthin your head: one rule per distinct width visited. Imagine 500 list rows doing this.
Code & diagrams
Three buttons but only two `<style>` tags: Cancel and Help produce the same CSS text, so they share one class. The insertion effects ran before the layout effect, so it already measures the 16px padding. The `$primary` prop never reaches the DOM. There is no CSS for the buttons in the CSS tab: it was all generated in the browser.
Preview
Console
Nothing logged yet.Expected console output
insert <style> for mini-myk4ct
insert <style> for mini-9qvyx9
layout effect sees padding: 16px
style tags: 2
<button class="mini-myk4ct">Save</button>Bar A adds a rule for every new width (2 at the start, 7 after five moves); drag the slider by hand and it keeps growing, and each insertion makes the browser recalculate styles. Bar B's CSS text never changes, so it keeps one rule forever and only the `--w` variable changes.
Preview
Console
Nothing logged yet.Expected console output
style tags at start: 2
style tags after 5 moves: 7// app/page.tsx is a Server Component by default (no "use client")
import { Button } from "./Button"; // Button.tsx uses styled-components
export default function Page() {
return <Button $primary>Save</Button>;
}
// Next.js stops with an error. The key sentence:
// It only works in a Client Component but none of its parents are marked
// with "use client", so they're Server Components by default.
//
// Fixes:
// 1. Mark Button.tsx (or a parent) with "use client" and add the style registry
// to the root layout. The button and the library now ship to the browser.
// 2. Or style it with CSS Modules, Tailwind or a zero-runtime library,
// which need no client JavaScript at all.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Forward a styling prop to the DOM
In the first example, replace the domProps line with const domProps = props; so $primary reaches the <button>.
Myth vs fact
Myth
CSS-in-JS means inline styles.
Fact
CSS-in-JS libraries generate real CSS classes and rules (with :hover, media queries and nesting). Inline styles are the style attribute and can't do those things.
Myth
CSS-in-JS is dead.
Fact
Runtime CSS-in-JS lost favour for new Server Component apps, but it still runs a huge number of existing apps, and zero-runtime CSS-in-JS (vanilla-extract, Panda, StyleX) is very much alive.
Myth
useInsertionEffect is a good place to update state or read layout.
Fact
It is for injecting styles only. Refs aren't attached yet when it runs and it can't schedule updates; use useLayoutEffect or useEffect for anything else.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
useInsertionEffect(React 18) fires after React has computed the DOM mutations and before layout effects. Inserting rules any later forces the browser to recalculate styles after layout has already been read; inserting during render (what older library versions did) breaks concurrent rendering because a render may be thrown away. - ▸
styled-components and Emotion cache rules by the hash of the CSS text, so identical components share a class. Interpolating highly variable values (positions, widths, animation frames) defeats that cache. Their own docs recommend
.attrsorstylefor frequently changing values. - ▸
The maintainers of styled-components announced in 2025 that the library is in maintenance mode, and Emotion's App Router support is still listed as in progress in the Next.js docs. That status, plus Server Components, is why the ecosystem moved to Tailwind, CSS Modules and compile-time tools.
- ▸
Atomic CSS-in-JS (StyleX, Panda) compiles each declaration to one reusable class (
.x1abc { padding: 8px }), so total CSS stays small however many components there are, the same property that makes Tailwind's output small.
Remember this
- 1
CSS-in-JS means writing CSS inside your JavaScript files, attached to components. The best-known libraries are styled-components and Emotion. With styled-components you write
styled.buttonfollowed by a template string of CSS, and functions inside the string, such as${(p) => (p.$primary ? "teal" : "white")}, read the props. This is a tagged template literal (a template string passed to a function), and the result is a React component with its styles built in. Emotion offers the samestyledAPI plus acssprop:<div css={{ padding: 8 }} />. - 2
Why people liked it: styles are scoped automatically (each component gets generated class names, like CSS Modules), styles can use props and theme values directly, unused components take their styles with them, and everything lives in one file. A
ThemeProviderputs design tokens in React context so any styled component can readtheme.colors.primary. - 3
How a runtime library works, step by step: (1) while your component renders, the library runs the interpolation functions with the current props to get the final CSS text; (2) it hashes that text into a class name such as
sc-a1b2c3; (3) if that class is new, it inserts a CSS rule into a<style>tag in the page; (4) it renders the element with that class. Step 3 must happen before any layout effect measures the element, which is why React 18 added **useInsertionEffect(Topic 7.8): a hook that runs after React has worked out the DOM changes but before** layout effects, meant only for libraries that inject styles. You'll build this whole machine in about 30 lines below. - 4
The runtime cost: all of that runs in the browser, on every render of every styled component. Serialising and hashing CSS takes time, and every new combination of prop values creates a new class and a new rule (a slider that interpolates
width: ${w}pxcreates a rule for every pixel it visits). Inserting rules makes the browser recalculate styles for the page. The library's code also has to download (styled-components is roughly 12 to 16 KB gzipped). For most apps this is fine; for big lists or animations driven by props, it shows up in profiles. The fix inside these libraries is the same as in Topic 11.1: put fast-changing values in CSS variables orstyle, not in the CSS text. - 5
Why it clashes with Server Components (Phase 20): a Server Component runs only on the server and sends no JavaScript to the browser, and it can't use hooks or context. Runtime CSS-in-JS needs exactly those: hooks to inject styles, context for the theme, and library code in the browser. So every styled component must be a Client Component (
"use client"), the library must collect the CSS generated during server rendering and send it in the HTML before the elements that use it (Next.js calls this a style registry, built withuseServerInsertedHTML), and streaming makes this harder. Importing a runtime CSS-in-JS component into a Server Component makes Next.js stop with an error saying it only works in a Client Component. - 6
Zero-runtime CSS-in-JS keeps the authoring style but moves the work to build time: the bundler plugin runs your style definitions, writes a normal
.cssfile, and replaces them with plain class-name strings. Examples: vanilla-extract (styles in.css.tsfiles,style({ padding: 8 })), Linaria (acsstagged template), Panda CSS (css({ px: 4 })with generated utilities) and Meta's StyleX (stylex.create, compiled to atomic classes). Dynamic values become CSS variables. They work with Server Components because nothing runs in the browser. The trade-off is that styles can't be computed from arbitrary runtime data; you choose from variants decided at build time. For new projects, CSS Modules, Tailwind or a zero-runtime tool are the usual picks; styled-components and Emotion remain common in existing codebases.
Explain it without notes
Describe step by step what a runtime CSS-in-JS library does when a styled component renders.
What is useInsertionEffect and why was it added?
Why does runtime CSS-in-JS conflict with React Server Components?
What is zero-runtime CSS-in-JS and what do you give up with it?
Practice
Using the mini styled() from the first example, create a Card (a div) whose border colour comes from a $tone prop ("info" → #1c7ed6, "warn" → #f59f00) and render one of each.
Rewrite Bar A from the second example so it keeps a single CSS rule however the width changes, without using CSS variables.
Trade-offs
- ↔
Runtime CSS-in-JS gives the most flexible authoring (any prop, any theme value, colocated styles) at the cost of browser work, bundle size and Server Component friction.
- ↔
Zero-runtime tools keep typed, colocated styles with static CSS output, but need a build plugin and push truly dynamic values into CSS variables.
Done when you can
I can explain what CSS-in-JS is and why teams adopted it.
I can describe how a runtime library turns props into a class and a
<style>rule.I can explain what
useInsertionEffectis for.I can explain the runtime cost and how CSS variables reduce it.
I can explain why runtime CSS-in-JS clashes with Server Components and name zero-runtime alternatives.