Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~27 min· topic 4 of 6

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.button followed 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.

Button.jsxwhole filejsx
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.

Insert: a rule in a <style> tag before layout effectsdiagram
Rendering diagram…

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.

app/registry.jsx (styled-components with the Next.js App Router)whole filejsx
"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.

button.css.ts (vanilla-extract)whole filetsx
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>
Zero-runtime: the same idea at build timediagram
Rendering diagram…

Try it yourself

  1. 1

    Give Help its own look

    In the first example, add a $danger prop to Help and a line border-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. 2

    Switch the insertion effect for a normal effect

    Change useInsertionEffect to useEffect in 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. 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]").length in your head: one rule per distinct width visited. Imagine 500 list rows doing this.

Code & diagrams

Build a tiny styled() with useInsertionEffect React 18+ New tab

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

Press Run to see the page.

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>
The runtime cost: a new rule for every value New tab

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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

style tags at start: 2
style tags after 5 moves: 7
The Server Components clash, in a Next.js App Router projecttsx
// 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>.

terminal
$ Console
── what you'll see ──
Received `true` for a non-boolean attribute `$primary`.
 
If you want to write it to the DOM, pass a string instead: $primary="true" or $primary={value.toString()}.
Invalid attribute name: `$primary`

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 .attrs or style for 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. 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.button followed 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 same styled API plus a css prop: <div css={{ padding: 8 }} />.

  2. 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 ThemeProvider puts design tokens in React context so any styled component can read theme.colors.primary.

  3. 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. 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}px creates 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 or style, not in the CSS text.

  5. 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 with useServerInsertedHTML), 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. 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 .css file, and replaces them with plain class-name strings. Examples: vanilla-extract (styles in .css.ts files, style({ padding: 8 })), Linaria (a css tagged 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

01

Describe step by step what a runtime CSS-in-JS library does when a styled component renders.

02

What is useInsertionEffect and why was it added?

03

Why does runtime CSS-in-JS conflict with React Server Components?

04

What is zero-runtime CSS-in-JS and what do you give up with it?

Practice

01

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.

02

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 useInsertionEffect is 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.