What CSS-in-JS Libraries Generate and How the Output Performs
CSS-in-JS libraries inject styles at runtime or extract them at build time. See the actual CSS each approach emits and understand the performance cost.
CSS-in-JS output performance is not about how pretty your button looks. It is about what bytes the browser receives, what work the style engine does when those bytes arrive, and what happens when a prop changes and the tooling has to redo some of that work. The libraries sell you ergonomics. The browser charges you in style recalculation. This page shows the actual emitted CSS for the same styled button from three approaches: a runtime solution like styled-components, a build-time extractor like vanilla-extract, and hand-written CSS with custom properties. It tells you what each costs in bytes, in layout and paint, and in the loss of the cascade.
How a Runtime Library Emits CSS
Start with the runtime approach. styled-components takes your tagged template literal and, at render time, generates a hashed class name, builds a CSS rule, and injects it into a tag in the . Run the same component twice with different props and the tool creates a second rule, a second class, a second injection. It does not replace the first. It appends. The style tag grows with every unique combination of props and every mounted instance. For a button with a primary variant, that is two rules. For a button with a theme toggle, a size prop, and a disabled state, you get a combinatorial explosion of rules, each one carrying the full declaration block.
Here is what styled-components emits for a simple button with a dynamic background color:
/* styled-components runtime output */
.css-1a2b3c {
background-color: blue;
color: white;
padding: 8px 16px;
border-radius: 4px;
}
.css-4d5e6f {
background-color: red;
color: white;
padding: 8px 16px;
border-radius: 4px;
}
Two rules, two hashed class names, both in the same tag. The browser parses both, stores both in the CSSStyleSheet, and matches both against the DOM. Only one applies at a time, but the other is still there, occupying memory and participating in selector matching on every style recalculation. Render fifty buttons with different colors and you get fifty rules. This is the runtime cost that documentation rarely shows you.
What Happens When a Prop Changes
Now change a prop at render time. The library does not edit the existing rule. It generates a new class name and injects a new rule into the style tag. The old rule stays. The DOM node’s class attribute changes from one hashed name to another. The browser then recomputes style for that element, and because the style tag grew, selector matching has more rules to walk. Change props in a tight loop, say, dragging a slider that updates a margin, and you inject a new rule every frame while triggering style recalculation each time. The fix, if you must use a runtime library, is to lean on custom properties as the dynamic value. The tool injects the component’s static shell once and the prop change updates a var() on the element or a parent. That moves the cost from rule injection to property inheritance, which the browser optimises far better.
Byte Cost and the Zero-Runtime Alternative
Measure the byte cost. The two rules above, before gzip, are small. After gzip, smaller still. Trivial for one button. But a page with thirty styled components, each generating two to three variant rules, adds up to several kilobytes of injected CSS that would otherwise not exist. Worse, the JavaScript bundle that generates it, the styled-components runtime itself, is around 15KB gzipped. That code must parse, execute, and maintain the injection logic before the first styled component even renders. Compare that to a build-time extractor like vanilla-extract, which pulls your styles out at build time into a static .css file. The button rule is identical in shape but arrives in a file the browser parses once, with no JavaScript involved. The hashed class names remain, vanilla-extract uses them to scope styles, but the injection mechanism is gone. The CSS-in-JS versus CSS Modules comparison lands here: CSS Modules also produce static .css files with locally scoped class names, and they carry no runtime. The only reason to choose a runtime CSS-in-JS package over CSS Modules is if you need dynamic theming based on JavaScript state that cannot be expressed as a custom property. Even then, the custom property route is cheaper.
CSS-in-JS Runtime Cost
The runtime cost of CSS-in-JS is not the injection itself. The injection is a few DOM operations. The cost is the cascade you lose and the style recalculation you trigger. When every rule is scoped to a single hashed class, you cannot write a bare element selector that resets a default. You cannot rely on inheritance from a parent class. The cascade, the most powerful mechanism in CSS, origin, layer, specificity, source order, is flattened into a pile of single-class rules that all have the same specificity. Two components disagree about a padding value. The later-in-source-order rule wins, not the more specific one, because there is no more specific one. You are back to specificity wars, but now the weapons are generated class names you cannot read.
styled-components Emitted CSS
Look at the emitted CSS from styled-components once more. It is valid CSS. It parses, it applies, it works. But it is CSS that was generated by JavaScript at render time, which means the browser cannot see it until the JavaScript runs. Server-side rendering pushes that to the server, but the client still must inject the styles into the DOM before first paint to avoid a flash of unstyled content. The style tag is not in the initial HTML. A script appends it. That delays the first style calculation and pushes the critical CSS decision to the library’s runtime logic. A build-time extractor gives you a .css file the browser loads in parallel with the JavaScript, and styles apply as soon as the stylesheet parses. No script required.
Zero-Runtime CSS-in-JS
Vanilla-extract is the representative build-time extractor. You write your styles in TypeScript, in .css.ts files, and the build tool (Vite, webpack, etc.) compiles them to static .css. Here is the same button, with the same dynamic background, expressed as vanilla-extract:
/* vanilla-extract build output */
.Button_variant_primary__1a2b3c {
background-color: blue;
color: white;
padding: 8px 16px;
border-radius: 4px;
}
.Button_variant_secondary__4d5e6f {
background-color: red;
color: white;
padding: 8px 16px;
border-radius: 4px;
}
Same two rules, same hashed class names, but they live in a .css file the browser fetches with a <link> tag. No injection, no runtime, no JavaScript execution before styles apply. The byte cost mirrors the styled-components output, but the JavaScript bundle does not carry the 15KB runtime. The browser parses the stylesheet once and never updates it when props change, because the dynamic value is not in the CSS at all. It is a custom property the component sets inline. That is the design token pattern: the static rule uses var(--button-bg), and the component sets --button-bg on its own element or a parent. The cascade carries the change, and the browser handles it in the property inheritance stage, far cheaper than injecting a new rule.
CSS-in-JS vs CSS Modules
CSS Modules are the older sibling of build-time CSS-in-JS. You write a .module.css file with ordinary CSS, and the build tool generates hashed class names and a static .css output. The difference from vanilla-extract is that CSS Modules do not let you compute values in JavaScript at build time without a preprocessor, and they do not encourage the same design token flow. But for the browser, the emitted CSS is identical: a static stylesheet with locally scoped class names. The choice between CSS Modules and a build-time CSS-in-JS package is a JavaScript tooling decision, not a CSS decision. The CSS that reaches the browser is the same shape. The real question is whether you want to author styles in a .css file or in a .ts file. If you need to share design tokens between your JavaScript and your CSS, build-time CSS-in-JS packages give you a type-safe bridge. If you want scoped styles, CSS Modules are simpler and carry no extra dependency. The performance profile is identical. The decision is about maintainability, not bytes.
Hand-Written CSS With Custom Properties
The third approach is the one to reach for when you control the markup and do not need component isolation from a framework. Write a plain .css file, use a class for the button, and use custom properties for the dynamic value. Here is the same button:
/* hand-written CSS */
.button {
background-color: var(--button-bg, blue);
color: white;
padding: 8px 16px;
border-radius: 4px;
}
.button--secondary {
--button-bg: red;
}
That is one rule, not two. The secondary variant is a modifier class that flips the custom property. The browser loads one stylesheet, parses it once, and the cascade handles the variant. To change the background from JavaScript, set the custom property on the element: element.style.setProperty('--button-bg', 'green'). That triggers a style recalculation for that element and its descendants. It does not add a rule to the stylesheet, does not grow the style tag, and does not parse anything new. The byte cost is lower, and the runtime cost is confined to the property inheritance stage, the cheapest way to change a style.
What a Declaration Actually Costs in Layout, Paint, and Composite
The three approaches above all emit the same visual result, but the cost of changing a prop differs. Change background-color and you trigger a paint cost. The browser repaints the element’s pixels. It does not trigger layout, because background-color does not affect the box model. That is a low paint cost. But if the dynamic prop is width, height, margin, or padding, you trigger layout cost, which is high. The browser recomputes the geometry of the element and potentially all of its siblings and descendants. If the dynamic prop is transform or opacity, the browser can promote the element to the compositor and handle the change without touching layout or paint at all. That is the compositor-only path, and it is the cheapest. The mistake is assuming all prop changes are equal. They are not. Changing a custom property that drives a transform is effectively free. Changing one that drives a margin is a layout recalculation. The library does not change that. The declaration does.
The Loss of the Cascade and the Specificity Trap
When every style is scoped to a single element via a hashed class, you lose the ability to write a bare selector that resets a default. In a plain .css file, you can write .card > p { margin-top: 0; } and it applies to any paragraph inside a card, regardless of the paragraph’s own classes. In CSS-in-JS, you would have to create a component for that paragraph and attach a style to it, or use a selector that targets the generated class of the parent. That is fragile because the hashed name changes with every build. The cascade is not a feature you can opt out of. It is the mechanism that makes CSS composable. Removing it means every component must re-declare the full set of styles it depends on, which increases the size of the emitted CSS and the specificity of every rule. A simple design system becomes a pile of rules that can only be overridden by more specific generated classes. The source order of the style tags becomes the only thing keeping the page from breaking.
The Jurisdiction Issue: JavaScript Tooling, Not a CSS Decision
CSS-in-JS is not a web platform feature. No browser engine ships CSS-in-JS natively. What ships is the CSS that the tool generates, and that CSS is a plain stylesheet with hashed class names. The choice of package is a JavaScript tooling decision. It affects your build, your bundle, your authoring experience. The runtime behaviour is CSS, subject to the same style recalculation model, the same cascade, the same specificity rules as any other CSS. The performance claims in library documentation are not claims about the browser. They are claims about the library’s implementation, and they are often optimistic. The browser engine does not care whether the rule came from a tagged template or a .css file. It parses both into the same CSSStyleSheet, matches both against the DOM, and recalculates style at the same cost. The only difference is when the rule arrives and how much JavaScript had to run before it did.
Common Mistakes and What to Do Instead
Defining Styles Inside the Render Function
The most common mistake with a runtime CSS-in-JS package is defining the style object or tagged template inside the render function of a component. Every render creates a new style instance, and the tool injects a new rule for it. That is a style recalculation on every render, and it can cause layout thrashing if the component is large. Define the style outside the component, as a module-level constant. The tool creates the rule once and reuses it.
Interpolating Frequently Changing Values
The second mistake is using dynamic prop interpolation for values that change frequently: scroll position, mouse coordinates, anything that updates more than a few times per second. Each update injects a new rule, and the style tag grows unbounded. Move those values to custom properties and set them via style.setProperty. That updates the property without touching the stylesheet.
Assuming SSR Eliminates the Runtime Cost
The third mistake is assuming server-side rendering eliminates the runtime cost. It does not. The client still must inject the styles before first paint, and the style tag is not in the initial HTML unless you inline it, which adds bytes to the document.
FAQ: Four Questions About CSS-in-JS Output Performance
Does CSS-in-JS have a runtime cost even with server-side rendering?
Yes. Server-side rendering generates the HTML and the CSS, but the client still runs the library’s runtime to inject the styles into the DOM if you are using a runtime package. The injection happens after the HTML arrives, so first paint is delayed until the script executes. Build-time extractors avoid this by emitting a static .css file.
Can I use CSS-in-JS and still get the compositor-only benefit for animations?
Yes, but only if the animated declaration is transform or opacity. Those can be handled by the compositor without layout or paint. Animate a custom property that drives a transform and the compositor still applies. Animate a custom property that drives margin and you trigger layout. The tool does not change the declaration’s cost.
Does zero-runtime CSS-in-JS have the same hashed class names as runtime?
Yes, both use hashed class names for scoping. The difference is where the hashing happens. Runtime packages hash at the client, injecting the rule on demand. Build-time extractors hash at build time, so the .css file is static. The hashed name format is similar, and the browser treats them identically.
Is CSS Modules the same as zero-runtime CSS-in-JS?
The output is the same: a static .css file with locally scoped class names. The authoring experience differs. CSS Modules use a .module.css file with plain CSS syntax. Build-time extractors like vanilla-extract use TypeScript files that compile to CSS. Choose based on whether you need type-safe design tokens, not on performance. The emitted CSS is equivalent.
What the Numbers Look Like in 2026
The Dominant Libraries
The dominant packages now are styled-components, Emotion, vanilla-extract, Panda CSS, Pigment CSS (MUI), and StyleX (Meta). The trend is clear: build-time extractors have largely displaced runtime packages for new projects. The reasons are not aesthetic. They are the byte cost of the runtime and the style recalculation cost of runtime injection.
Atomic CSS and the Byte-Cost Ceiling
StyleX, used by Meta, compiles to atomic CSS. Each declaration becomes a single class, like .color-red and .font-bold, which reduces the emitted CSS to the union of unique declarations across the page, not the product of component variants. That is the atomic CSS approach, and it is the most aggressive in terms of byte reduction. Atomic CSS does not fix the cascade problem. It makes it worse, because every element has a long list of atomic classes and specificity is flat. But if your goal is the smallest possible .css file, atomic CSS wins.
The Honest Caveat
The browser’s style recalculation engine does not care about the library’s marketing. It cares about the number of rules, the specificity of those rules, and the declaration that changes. A hand-written .css file with custom properties is still the cheapest option, because it has the fewest rules and the lowest specificity. Build-time extractors close the gap to within a few bytes, but they cannot beat a single well-written rule.