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/bamboois 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.tsxandchart-utils.tsare the same bytes in every app. - Same baseline reset. Engines that ship one use theirs; engines that do not vendor Bamboo's
preflightverbatim, 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.