§2.8 + §2.11 · Pair with lesson 0015-design-system-styling.html · Rapid-fire: “CSS-in-JS runtime cost vs zero-runtime”
Design system = product, not folder: tokens (decisions) → components (behavior+a11y+look) → governance (versioning, docs, contribution model).
Why a Lead cares: 5 teams × 5 datepickers = bundle bloat (LCP/INP), N bad a11y implementations, brand drift. The system is a perf + velocity instrument.
| Pattern | One-liner |
|---|---|
| Composition > inheritance/config | Prop explosion (Nth boolean) → open children/slots instead. React has no inheritance on purpose. |
| Controlled vs uncontrolled | Parent owns state (value/onChange) vs component owns it (defaultValue/ref). Libraries ship both, like the DOM <input>. |
| Compound components | Tabs.List/Tab/Panel share implicit state via context created in the parent. Cure for prop explosion. Trap: pieces break outside parent; portals can sever context. |
| Render props → hooks | Hooks replaced them for logic reuse. Render props survive where the component controls what/where/how-many renders (virtualized rows, table cells). |
| Headless | Behavior+state+a11y, zero styles (Radix, React Aria, TanStack Table). You bring the look. |
Headless core + token-driven styled layer per brand. Keyboard/focus/ARIA solved once centrally; new brand = new token set + thin wrappers, not a fork.
“When is the CSS produced — build time or render time?”
| Approach | CSS at | Verdict |
|---|---|---|
| CSS Modules / Sass | build | zero runtime, cacheable .css |
| styled-components / Emotion | render | per-render tax, no RSC |
| vanilla-extract / Linaria / Panda / StyleX | build | CSS-in-TS DX, static output, RSC-safe |
| Tailwind | build | tokens-as-utilities, purged, RSC-safe; verbose markup |
Mechanism (say it): runtime CSS-in-JS serializes + inserts rules during render, on the main thread, again on re-render; styles ship as JS not cacheable CSS.
Receipt: Emotion's #2 maintainer (Magura/Spot): 54ms → 27.7ms (≈48% faster) moving to Sass Modules.
Kill shot: runtime CSS-in-JS can't run in Server Components → everything becomes "use client", defeating RSC's zero-JS goal (Next.js docs).
--blue-500: #5392f9; /* 1 primitive (never use directly) */
--color-action-primary:
var(--blue-500); /* 2 semantic = THE decision */
--button-bg:
var(--color-action-primary);/* 3 component (optional) */
Components consume semantic only → dark mode / new brand = re-map semantic layer, zero component edits.
Fresh fact: W3C DTCG shipped the first stable token format spec — 2025.10 (vendor-neutral JSON, aliases, color spaces) → standard interchange Figma ↔ Style Dictionary ↔ platforms.
@media (prefers-color-scheme: dark) on :root:not([data-theme]).[data-theme="dark"] overrides.<head> sets data-theme before first paint. useEffect = guaranteed flash (runs after paint — L02 CRP).@layer: later layer beats earlier regardless of specificity; unlayered beats all layers. Ship library CSS in a named layer → consumers override without !important. Ends the specificity war structurally.
Container queries: container-type: inline-size + @container (min-width: …) — component adapts to its container, not viewport. Same card: wide in results, stacked in sidebar, same screen. THE design-system responsive primitive.
sideEffects, per-component entries) — Button users don't ship DataGrid.@layer-wrapped CSS."use client" everywhere. This is the 2026 deciding argument.var(--blue-500) in a component defeats theming. Semantic layer or it didn't happen.useEffect → flash of wrong theme. Inline head script, before paint.@layer.