GitHub - gajus/css-in-js-arena: Comparison of modern CSS-in-JS frameworks.

GitHub

9 min read Original article โ†—

A benchmark harness for compile-time CSS engines. Each engine gets its own React Router 8 app under apps/, and every app renders the same six-page admin console โ€” identical markup, identical design, identical data. The apps are verified pixel-identical before anything is measured, so the numbers isolate the engine and nothing else.

Engine Integration Version
Bamboo CSS @bamboocss/vite 1.46.2
StyleX @stylexjs/unplugin 0.19.0
Panda CSS @pandacss/postcss 1.12.0

Measured 2026-08-20 at the versions above ยท macOS, Node 24.10, Vite 8.2.1

Engine Shipped bytes Build & dev Authoring Correctness & maintenance Rows won ๐Ÿ†
Bamboo ๐Ÿ† 9 / 10 ๐Ÿ† 3 / 6 ๐Ÿ† 7 / 8 ๐Ÿ† 4 / 4 ๐Ÿ† 23 / 28 ๐Ÿ†
StyleX 5 / 10 1 / 6 2 / 8 1 / 4 9 / 28
Panda 2 / 10 2 / 6 6 / 8 1 / 4 11 / 28

Rows won per category, out of the scored rows in each. They are not equally weighted and two are unscored, so the tally is a scanning aid rather than the judgement โ€” and the byte margins do not survive scale, as the next section shows.


Full results

Axis Bamboo ๐Ÿ† StyleX Panda
Shipped bytes
Full first load 104,938 B ๐Ÿ† 106,117 B ๐Ÿ† 112,840 B
CSS, brotli 6,825 B ๐Ÿ† 7,008 B 9,518 B
CSS, gzip 7,909 B ๐Ÿ† 8,176 B 11,524 B
CSS, raw 37,326 B ๐Ÿ† 40,430 B 54,007 B
CSS rules emitted โ€” not a quality axis 525 467 532
Client JS, brotli 92,569 B ๐Ÿ† 93,583 B ๐Ÿ† 97,778 B
SSR HTML, gzip (mean of 6) โ€” tie, spread 0.3% 5,544 B 5,526 B 5,544 B
Class attribute bytes, raw 93,036 B 70,843 B ๐Ÿ† 92,738 B
โ€” on the selector-heavy route 11,728 B ๐Ÿ† 11,754 B ๐Ÿ† 11,685 B ๐Ÿ†
Unreachable CSS shipped 0 B ๐Ÿ† 344 B n/a โ€” runtime
Orphan file in include (50 styles), imported by nothing +0 B ๐Ÿ† +0 B ๐Ÿ† +13,200 B
Stylesheets emitted 1 ๐Ÿ† 2 โ€” one unreferenced 1 ๐Ÿ†
Build & dev
Production build, cold 1,484 ms ๐Ÿ† 2,213 ms 1,611 ms
Production build, warm 1,490 ms ๐Ÿ† 2,348 ms 1,593 ms
Dev server cold start 1,642 ms 1,409 ms 1,328 ms ๐Ÿ†
HMR โ€” edit a shared style module 162 ms 100 ms ๐Ÿ† 137 ms
HMR โ€” edit a component file 191 ms 227 ms 137 ms ๐Ÿ†
HMR payload, one shared edit 336 KB ยท 9 ๐Ÿ† 356 KB ยท 10 402 KB ยท 9
Authoring
Total lines written 3,921 ๐Ÿ† 4,090 3,930 ๐Ÿ†
Structural & relational selectors one rule on the container ๐Ÿ† class per cell, last in JS one rule on the container ๐Ÿ†
Next-sibling selector (+) yes ๐Ÿ† ~ only, via when + a marker yes ๐Ÿ†
Variant recipes cva, typed matrix ๐Ÿ† compose per call site cva, typed matrix ๐Ÿ†
Light/dark theming 2 values per token ๐Ÿ† 3 values per token 4 values per token
Dynamic values inline style custom property ๐Ÿ† โ€” survives the cascade inline style
Register an @property (not via globalCss) global.vars ๐Ÿ† stylex.types.* ๐Ÿ† globalVars ๐Ÿ†
Animate a registered property yes ๐Ÿ† declaration dropped โ€” no keyframe, no rule yes ๐Ÿ†
Correctness & maintenance
Mistyped token name build fails ๐Ÿ† TS error, build succeeds not caught at all
Mistyped property name caught (TS2561) ๐Ÿ† ships pading-block caught (TS2561) ๐Ÿ†
Delete a page โ†’ CSS shrinks โˆ’20.0% ๐Ÿ† โˆ’8.4% โˆ’13.5%
Class names folded to literals 522 / 522 ๐Ÿ† 453 / 458 ๐Ÿ† 25 / 529 โ€” rest computed in the browser, from a 14.7 KB runtime chunk
Rows won, of 28 scored ๐Ÿ† 23 9 11

Where the main table doesn't generalise

Everything above is one app in one configuration. Two things move the answer: how many styles the app has, and whether it ships more than light and dark.

Style volume

The main table describes an app of 570 rule blocks. Real applications run an order of magnitude past that, where fixed overhead stops mattering and marginal cost per rule becomes everything.

tools/scale.mjs generates N style definitions with all-distinct values and measures the emitted stylesheet, isolating each engine's true cost per rule.

Downloaded stylesheet, brotli, relative to Bamboo:

Style definitions Bamboo StyleX Panda
0 โ€” the app as it ships ref +2.7% +39.5%
50 ref +17.7% +35.0%
200 ref +49.4% +27.3%
800 ref +101.5% +15.5%

Both challengers move, in opposite directions. The ranking at the arena's size is not the ranking at production size.

Marginal cost per declaration Gap to Bamboo at n=0 at n=800
Bamboo 40.3 B raw ยท 2.0 B brotli ref ref
Panda 40.3 B raw ยท 2.0 B brotli +16,681 B +16,681 B
StyleX 65.8 B raw ยท 5.5 B brotli +3,104 B +125,503 B

Panda's marginal cost is identical to Bamboo's, to the byte. Its whole penalty is a fixed 16,681 B of scaffolding โ€” the same constant at 0 styles and at 800 โ€” so "Panda ships 40% more CSS" is a statement about a small app, not about Panda. StyleX is the reverse: almost pure slope, growing 40ร— across the same range, because every rule carries :not(#\#) specificity padding that repeats per declaration and compresses poorly.

Theming

Brand themes are a different question from light/dark, and the arena app ships none โ€” so this is measured separately. tools/theming.mjs injects N themes through each engine's own multi-theme API (Bamboo theme.variants, Panda themes, StyleX createTheme), each overriding the same 18 colours with a light and a dark value.

Stylesheet the browser downloads, brotli:

Brand themes Bamboo StyleX Panda
0 6,825 B 7,008 B 9,518 B
2 6,825 B 7,427 B 9,518 B
8 6,825 B 8,350 B 9,518 B
added per theme 0 B ๐Ÿ† +168 B 0 B ๐Ÿ†

Theme payload, fetched only when a theme is selected:

Axis Bamboo StyleX Panda Margin
Bytes per theme 1,374 B ๐Ÿ† n/a โ€” in the stylesheet 2,805 B โˆ’51%
Themes in the critical path none ๐Ÿ† all of them none ๐Ÿ† StyleX has no lazy option

Two mechanisms, not three. Bamboo and Panda emit each theme as its own artifact, imported on demand, so first load is flat however many exist. StyleX's createTheme compiles into the linked stylesheet, so every visitor pays for every theme โ€” at eight, its CSS is 19% larger than at zero. Between the two lazy engines the gap is the same encoding difference as the light/dark row: Bamboo writes base and _osDark and lets light-dark() resolve the rest, Panda writes four values. That is 2.04ร— the bytes per theme.

This reverses for a site that ships one fixed brand theme and never switches: a lazy artifact is then a second request for bytes the stylesheet would have carried anyway.


What's measured

Ground rules

  • One reference app. apps/bamboo is the reference; every other is diffed against it, so all match each other transitively โ€” element for element, then pixel for pixel.
  • Shared source is byte-identical. data.ts, icons.tsx and chart-utils.ts are the same bytes in every app.
  • Same baseline reset. Engines that ship one use theirs; engines that do not vendor Bamboo's preflight verbatim, so nobody gets a typography head start.
  • Default configuration only. Each engine is measured as it ships. Opt-in settings are reported separately, never folded into the main table.

Pages

Every app renders these six routes identically:

Route What it exercises
/ KPI grid, SVG bar chart + sparklines, activity feed, responsive 2-col dashboard
/projects Data table, status badges, progress bars, toolbar, pagination
/settings Sticky section nav, 2-col form grid, validation states, toggle switches, radio cards, danger zone, sticky save bar
/pricing Featured pricing cards, billing toggle, comparison table, <details> FAQ
/docs 3-column docs layout, prose typography, code block, callouts, table, TOC
/lab Structural + relational selectors, keyframe motion, container queries

All six are responsive across three breakpoints and support system dark mode plus an explicit light/dark toggle.


Reproducing this

Every number here comes from one contiguous measurement session on one machine. The harness, the parity gate and the exact commands are in RUNNING.md.


FAQ

Why is CSS minification disabled?

build.cssMinify: false in all three apps. Vite's default runs Lightning CSS over the emitted stylesheet and rewrites it โ€” most visibly downlevelling light-dark() into a 54-variable polyfill under the baseline-widely-available target. That measures the downleveller rather than the engine, and penalises only engines emitting modern CSS. With it off, every stylesheet measured here is exactly what its engine wrote.

StyleX still shows some Lightning CSS output because @stylexjs/unplugin depends on it directly โ€” that is part of its product, not the harness.

What does the orphan-file row actually measure?

A module that matches the engine's include glob but that nothing imports โ€” the file a deleted feature leaves behind. tools/orphan.mjs writes one carrying 50 style definitions, rebuilds, and diffs the stylesheet.

Panda extracts from source text, so it scans the file and ships its CSS regardless of whether the bundle reaches it. Bamboo and StyleX both scope to the bundle graph and emit nothing. The 13,200 B is a property of the fixture; the finding is which engines are at zero.

How much weight do the two HMR latency rows carry?

Less than anything else in the table.

Edit-to-browser latency is bimodal for all three engines โ€” runs cluster near ~100 ms and ~200 ms rather than around one value โ€” so each median summarises a split distribution rather than a typical frame, and what separates the engines is how often each lands in the slow cluster. StyleX is the stable one, repeating to within 7% across repeated sweeps; Bamboo and Panda swing by 40โ€“80%.

The medians shown pool 40 runs per engine gathered over four sweeps in four different engine orders, because a single sweep drifts enough over its own runtime to hand whichever engine goes first a materially better number. Both winning margins here (39% and 37%) are far wider than that residual, so the ranking is solid even though the individual figures are not precise.

hmr-fanout.mjs also cannot isolate an engine's own work from Vite's HMR protocol, React Fast Refresh, or the socket round trip. The HMR payload row is a different matter โ€” it counts bytes, not milliseconds, and reproduces exactly.