Currently the CSS-in-JS ecosystem is split into two camps:
- Runtime CSS-in-JS (Emotion, styled-components) keeps JS on the client for full dynamism. At hydration it reuses the SSR-injected styles, so the CSSOM isn't duplicated but JS code still runs.
-
Zero-runtime (Linaria, Vanilla Extract, Panda CSS) removes runtime entirely: styles become static
.cssfiles at build time. The price is equally radical — dynamic styles are limited to CSS-native abilities such as Custom Properties, and you need a build plugin.
Both ask you to accept a trade-off: full dynamism or perfomative runtime. But I think it is an effect of the deeper problem.
I suppose we need to look at the deeper thing: every CSS-in-JS approach answers the same question: how do you map a piece of logic to a CSS selector? Runtime and zero-runtime give two different answers, and both tie the style to its identity. Understanding exactly what they tie together is the key to the third path.
Runtime Emotion CSS: the selector is a hash of the style
To keep the comparison fair, this article picks the vanilla runtime, @emotion/css — the fairest opponent, because it is already framework-agnostic: its css() is a plain function with no React and no render. The catch is how it names a style. On every call, css() runs the whole pipeline in @emotion/serialize, invoked from the core css() in @emotion/css — it evaluates the interpolations, assembles the CSS string and hashes it, returning <cacheKey>-<hash(styles)>:
const buttonClass = css`
color: white;
background-color: ${variant === 'primary' ? '#2192a7' : '#9d41ab'};
`;
Therefore, the classname is a function of the CSS text itself — the identity of the style is welded to its bytes. There is no name without the string. The only part that is cached is the DOM write: insertStyles skips re-insertion for a classname it has already seen, so the CSSOM isn't duplicated and the server work isn't wasted — but it is re-done. Because the selector derives from the string, retrieving an existing selector means rebuilding and re-hashing it. The coupling forces the recomputation.
Zero-runtime Vanilla Extract: the selector is a build-time constant
Vanilla Extract compiles a styles into static .css classes:
export const btn = style({
height: '1.5rem',
borderRadius: '0.25rem',
color: 'white',
});
export const btnBg = styleVariants({
primary: { backgroundColor: '#2192a7' },
secondary: { backgroundColor: '#9d41ab' },
});
Here the coupling goes the other way: the selector and the CSS are fixed together at build time, and both server and client use the same constant classnames. No generator ever runs — no recomputation, but also no freedom: the set of selectors is closed the moment you compile. style, styleVariants, etc. all come from the Vanilla Extract docs.
If you need a dynamic value you fall back to CSS variables (var(--btn-bg)) or to the Dynamic package, which adds a JS runtime and breaks the "zero-runtime" label. Fully static design systems — finite variants, known tokens — fit zero-runtime perfectly. But what if you need a style that wasn't enumerated at build time?
The pattern: style and selector are binded
Now we can see some patterns. Both tools bind the selector to a specific amount of "styles":
- Emotion binds
selector = hash(cssText). The selector is computed from the style. You cannot extract a selector without re-executing the generation. - Vanilla Extract binds
selector = name fixed at build. The selector is frozen together with the style. You never recompute, but you also never go off the precompiled list.
Both treat the selector as a derived value of the style. What if we make it an independent artifact? What if the mapping "logical name → selector" exists on its own, serializable, restorable and dynamic — while the CSS it points to is a separate, lazily generated thing?
That is exactly what EffCSS does. And once you have that split, styles' hydration becomes a nice bonus to the developer's experience.
EffCSS: style and selector are decoupled
EffCSS takes a slightly different approach. It doesn't strip styles at runtime like Vanilla Extract, nor does it run generator functions on the client like @emotion/css. It decouples the selector from the CSS it points to. The selector is generated from a namespace/ordinal, not from the CSS string, and the resulting mapping "logical name → minified selector" is kept as a separate dictionary — an isolated artifact:
import { classNames } from 'effcss';
export type TComponents = {
btn: {
bg: 'primary' | 'secondary';
};
};
export const clsResolver = classNames<TComponents>((selectors) => {
const { btn } = selectors;
return {
[btn]: {
height: '1.5rem',
borderRadius: '0.25rem',
color: 'white',
},
[btn.bg.primary]: { backgroundColor: '#2192a7' },
[btn.bg.secondary]: { backgroundColor: '#9d41ab' }
};
});
Because the selector is decoupled, the dictionary and the styles are two independently serializable artifacts. The library remembers everything it generated, so extracting them on the server side is a trivial task:
import { serialize, serializeMeta } from 'effcss';
// After rendering all components:
const html = renderToString(<App />);
// EffCSS styles
const css = serialize();
// <style data-effcss-key="f0">
// .f0_1 { height: 1.5rem; ... }
// </style>
// EffCSS metadata — the independent selector dictionary
const meta = serializeMeta();
// <script type="application/json" data-effcss-key="f0">
// {"btn":"f0_1","btn_bg_primary":"f0_2", ... }
// </script>
// add all to hydrate on the client side
const head = css + meta;
Serialization on the server side is described in more detail in the documentation.
Notice the two payloads. serialize() ships the CSS itself. serializeMeta() ships the selector dictionary — the mapping from logical names to selectors, restored later without re-executing any generator. These are independent, and that independence is the whole point.
Hydration is the payoff of the split
Because styles and selectors travel separately, hydration becomes a trivial lookup instead of a recomputation. What happens on the client:
- When the HTML loads, the browser parses the
<style>into CSSOM - standard behavior. - The first time you call
clsResolver({ btn: { bg: 'primary' }}), EffCSS looks for a<style[data-effcss-key]>element with server-generated CSS. - If found, it skips CSS creation.
- It finds the
<script>with the meta dictionary and restores the selector mapping without calling the generator function. - Then, using the dictionary, it simply computes the required selectors.
The key idea: EffCSS sees ready-made styles and metadata and reuses them. The generators are not re-run, because the generator's output — the selector dictionary — was shipped alongside. All that remains on the client is a dictionary lookup and, when you actually need a selector, a cheap resolution. Any combination of selectors from the dictionary can be used on the client.
No more style recomputation, but still full dynamism
This split stands EffCSS out in comparison to each camp:
- To runtime: @emotion/css re-run every interpolation and re-hash the string to get back an existing name. EffCSS doesn't have to: the selector was already a separate artifact on the server, so the client just restores the dictionary and reads it out.
- To zero-runtime: Vanilla Extract avoids recomputation by freezing the selector at build time — and with it, the whole set of styles. EffCSS selectors can be computed in new combinations. You unlock serverside-computed styles for free, and full runtime dynamism for whatever wasn't there.
So the unique feature of EffCSS hydration is that generators do not need to run again at all: we resolve selectors from a restored dictionary and the dictionary contains only what is implemented in styles.
Same benefit for CSR
I thought it was unfair that client-side rendering couldn't take full advantage of EffCSS, so I wrote a plugin for Viute. With vite-plugin-effcss, for CSR the generated CSS and metadata are injected into the HTML at both dev and build time. Client-side rendered apps get the same hydration behaviour without changing your application — just add the plugin to the Vite config:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import effcss from 'vite-plugin-effcss';
export default defineConfig({
plugins: [react(), effcss()],
});
The use of the plugin is described in detail in the documentation.
Key difference
The foundation — how each camp binds a style to its selector — determines everything else:
| @emotion/css (runtime) | Vanilla Extract (zero-runtime) | EffCSS | |
|---|---|---|---|
| Selector identity | Hash of the CSS text | Frozen constant from build | Independent dictionary entry (namespace/ordinal) |
| Style ↔ selector | Welded: name needs the string | Welded: name frozen with styles | Decoupled |
| Server CSS → client | Rebuilt, same <style> reused |
Static .css file |
Same <style>, reused |
| Generator on client | Re-runs on every css() call
|
Never runs | Only for new styles |
| Selector resolution | Re-serializes + re-hashes on every call | Build-time only | Restored from meta dict |
| Dynamic styles | Full support at runtime | CSS vars or special runtime package | Full support |
| CSR hydration | Always runtime | Always static | Via vite-plugin-effcss
|
| SSR extraction | Manual / @emotion/server
|
Build-time tooling |
serialize() + serializeMeta()
|
Conclusion
Runtime CSS-in-JS computes a selector from the style and pays for it by recomputing on the client. Zero-runtime CSS-in-JS freezes the selector with the style and pays for it by closing the set of styles. Both treat the selector as a binded value of CSS.
EffCSS makes the selector an independent artifact (that's why I call EffCSS "contract-first library"). Styles and the selector dictionary are serialized once and restored separately: since they are ready, nothing needs to be reconstructed, only selectors should be computed from a dictionary. So the hydration works similarly and predictably for both CSR and SSR.
Enjoy your Frontend Development!
Top comments (0)