CSS-in-JS: Runtime vs Zero-Runtime
The CSS-in-JS Spectrum
CSS-in-JS isn't one thing — it's a spectrum from fully runtime (styles computed and injected via JavaScript) to fully static (styles extracted to CSS files at build time). The trade-offs are dramatic: runtime solutions offer maximum dynamism but add JavaScript bundle size and block rendering. Zero-runtime solutions extract to static CSS but limit what you can do dynamically.
React Server Components pushed the conversation further — runtime CSS-in-JS doesn't work in server components because there's no JavaScript execution on the server to inject styles.
Think of the CSS-in-JS spectrum as a cooking analogy. Runtime CSS-in-JS (styled-components, Emotion) is like a chef cooking to order — every plate is custom, but service is slow during rush hour. Zero-runtime (vanilla-extract, Panda CSS) is like meal prep — dishes are prepared in advance and just need plating. The meals are the same quality, but prep-ahead serves faster at scale.
Runtime CSS-in-JS
How It Works
// styled-components example
import styled from 'styled-components';
const Button = styled.button`
background: ${props => props.variant === 'primary' ? '#3b82f6' : 'transparent'};
color: ${props => props.variant === 'primary' ? 'white' : '#3b82f6'};
padding: 0.75rem 1.5rem;
border: 2px solid #3b82f6;
border-radius: 8px;
cursor: pointer;
&:hover {
opacity: 0.9;
}
`;
// Usage
<Button variant="primary">Click me</Button>
At runtime, styled-components:
- Parses the template literal
- Evaluates prop-based interpolations
- Generates a unique class name
- Injects a
<style>tag into the document head - Applies the class to the element
The Performance Cost
- Bundle size: ~12-15KB gzipped for styled-components
- Runtime: Style parsing and injection on every render (cached, but initial cost exists)
- SSR complexity: Requires style extraction during server rendering to prevent flash of unstyled content
- React Server Components: Incompatible — no JavaScript execution in server components
Emotion — Slightly Lighter
import { css } from '@emotion/react';
const buttonStyle = css`
padding: 0.75rem 1.5rem;
border-radius: 8px;
background: var(--color-primary);
`;
// Or object syntax
const buttonObj = css({
padding: '0.75rem 1.5rem',
borderRadius: '8px',
background: 'var(--color-primary)',
});
Zero-Runtime CSS-in-JS
vanilla-extract — Type-Safe Static CSS
// button.css.ts
import { style, styleVariants } from '@vanilla-extract/css';
const base = style({
padding: '0.75rem 1.5rem',
borderRadius: '8px',
cursor: 'pointer',
border: 'none',
fontWeight: 500,
});
export const variants = styleVariants({
primary: [base, { background: '#3b82f6', color: 'white' }],
secondary: [base, { background: '#f3f4f6', color: '#1f2937' }],
ghost: [base, { background: 'transparent', color: '#3b82f6' }],
});
// Button.tsx
import { variants } from './button.css';
export function Button({ variant = 'primary', children }) {
return <button className={variants[variant]}>{children}</button>;
}
Key advantages:
- TypeScript type checking for all CSS values
- Extracts to static
.cssfiles at build time — zero JavaScript runtime - Works with React Server Components
- Co-located with components but produces real CSS
Panda CSS — Zero-Runtime with Utility Patterns
import { css } from '../styled-system/css';
function Card() {
return (
<div className={css({
padding: '6',
borderRadius: 'lg',
bg: 'white',
shadow: 'md',
_hover: { shadow: 'lg' },
})}>
Content
</div>
);
}
Panda CSS scans your files at build time, generates utility CSS, and outputs static stylesheets. It combines the DX of styled-components with the performance of static CSS.
Comparison Matrix
| Feature | styled-components | Emotion | vanilla-extract | Panda CSS | CSS Modules | Tailwind |
|---|---|---|---|---|---|---|
| Runtime JS | Yes (~15KB) | Yes (~11KB) | None | None | None | None |
| TypeScript | Limited | Limited | Full | Full | External | External |
| Dynamic props | Full | Full | Build-time only | Build-time only | None | None |
| RSC compatible | No | No | Yes | Yes | Yes | Yes |
| SSR setup | Complex | Complex | None | None | None | None |
| Colocation | Excellent | Excellent | Good | Good | Good | Inline |
Why runtime CSS-in-JS is declining
Three forces pushed the ecosystem toward zero-runtime:
-
React Server Components: No JavaScript on the server means no runtime style injection. styled-components simply doesn't work in server components.
-
Performance awareness: Google's Core Web Vitals penalize Total Blocking Time. Runtime CSS-in-JS adds JavaScript that blocks the main thread during style calculation and injection.
-
CSS has caught up: CSS custom properties handle theming. :has(), container queries, and cascade layers solve problems that previously required JavaScript. The "escape from CSS" motivation is weaker now.
"Zero-runtime" doesn't mean zero build cost. vanilla-extract and Panda CSS both add complexity to your build pipeline. They need bundler plugins, can slow down builds on large codebases, and have their own learning curves. The zero-runtime label refers to browser runtime — the cost shifts to build time.
| What developers do | What they should do |
|---|---|
| Using styled-components in a Next.js App Router project with Server Components Runtime CSS-in-JS requires client-side JavaScript, which doesn't exist in Server Components | Use vanilla-extract, Panda CSS, CSS Modules, or Tailwind for RSC-compatible styling |
| Choosing CSS-in-JS solely because it co-locates styles with components Co-location is available in all modern approaches — it's not unique to CSS-in-JS | CSS Modules also co-locate styles. Evaluate based on runtime cost, type safety, and RSC compatibility. |
| Assuming zero-runtime means zero cost The trade-off is browser performance for build complexity | Zero-runtime shifts cost to build time. Build pipeline complexity, compilation speed, and tooling overhead still exist. |
| Using runtime CSS-in-JS for styles that don't depend on props/state Runtime CSS-in-JS is only justified when styles genuinely depend on runtime values that can't be handled by CSS custom properties | Static styles should always be static CSS (modules, Tailwind, vanilla-extract) |
- 1Runtime CSS-in-JS (styled-components, Emotion) adds JS bundle size and is incompatible with React Server Components
- 2Zero-runtime solutions (vanilla-extract, Panda CSS) extract to static CSS at build time — no browser JS cost
- 3CSS custom properties + CSS Modules handle most use cases that previously required CSS-in-JS
- 4Use runtime CSS-in-JS only when styles genuinely depend on runtime values that CSS variables can't express
- 5For new projects with Next.js App Router: prefer CSS Modules, Tailwind, vanilla-extract, or Panda CSS