lab · exploring

What the tokens cost

A measured report on the CSS the token set produces: size by layer, what each new color family adds, where the bytes come from (the colorPalette remaps), whether Panda's `hash` option makes the shipped CSS smaller once it is gzipped or brotli-compressed, and what an end user's app downloads with and without tree-shaking the design system.

frameworksastro
Exploringpanelbadgepopover
scenarioThe verdict

What the new colors cost, and whether hashing is the fix.

Hashing is not the lever; tree-shaking is

Hashing shortens names, but Panda's names are repetitive, so gzip and brotli already compress them to almost nothing. Hashed names are random, so they compress worse. Full hashing halves the minified file but saves only 3% gzipped and adds +7% brotli. What end users actually pay for is that every app gets the CSS of every component. With the plugin and the fixes in 07, a landing page went from 115 KB to 26 KB gzip (05).

76 KB

docs CSS over the wire (brotli) · 127 KB gzip · 1,263 KB minified

1,727

custom properties, 1,180 of them --colors-*

18%

of the minified CSS is the 8 tone families + dynamic (231 KB; 4.9 KB brotli)

31–47 ms

parse time for every variant, hashed or not (difference within noise)

scenarioDoes Panda's hash help?

Same design system, same sources, four hash settings. What a browser downloads is the gzip or brotli column.

Docs CSS under each hash setting (standalone config, Panda 2.2.0)

Docs CSS under each hash setting (standalone config, Panda 2.2.0)
HashConfigMinifiedgzipbrotli
Off (today)hash: false1,258 KB (0%)127 KB (0%)76 KB (0%)
Class names onlyhash: { className: true }863 KB (-31%)116 KB (-8%)74 KB (-2%)
CSS vars onlyhash: { cssVar: true }1,026 KB (-18%)137 KB (+8%)84 KB (+11%)
Bothhash: true631 KB (-50%)123 KB (-3%)81 KB (+7%)

Hashing class names only is the one setting that shrinks what's downloaded: 10 KB gzip,1.8 KB brotli. In exchange, devtools show .hryzeb where they now show .px_sm, and the recipe class names stop meaning anything. Hashing vars makes both compressed sizes larger.

62 token vars dangle under cssVar hash 264 hand-written var(--…) in 49 files consumers must match the lib's hash

panda lib bakes the setting into the design system: a consumer whose hash differs getsdesign_system_option_mismatch. Turning it on is a breaking change for every app, not a docs setting.

scenarioWhere the bytes are

Cascade layers of the docs CSS. The token declarations themselves are a small share.

Docs CSS by cascade layer

Docs CSS by cascade layer
Cascade layerMinifiedSharegzip (alone)
utilities1,059 KB84%103 KB
tokens111 KB9%16 KB
base (globalCss)91 KB7%7.2 KB
reset2.7 KB0%1.2 KB

The token layer, every ramp, surface and text value in light and dark, is 111 KB (9%). Most of the rest is utilities, and 253 KB of those (20%) are251 colorPalette rules, each re-pointing63 virtual tokens. They compress to2.8 KB brotli because they are so repetitive, but the browser still parses and stores every one.

An app that uses no components at all still gets1,168 KB (67 KB brotli),92% of the docs CSS. The design system's build info carries every recipe variant, and nothing tree-shakes it today (05 shows how to).

scenarioWhat each color family adds

The docs CSS minus one family's token declarations and every rule that names it.

8 tone families + dynamic: 231 KB · 19 KB gzip · 4.9 KB brotli 7 ramp-only families: 13 KB · 2.1 KB gzip

Marginal cost per family

Marginal cost per family
FamilyKindMinifiedgzipbrotli
tealtone family26 KB2.1 KB0.5 KB
cyantone family26 KB2.2 KB0.5 KB
violettone family26 KB2.2 KB0.6 KB
pinktone family26 KB2.2 KB0.5 KB
slatetone family26 KB2.2 KB0.6 KB
stonetone family26 KB2.1 KB0.4 KB
sagetone family26 KB2.1 KB0.7 KB
mauvetone family26 KB2.2 KB0.6 KB
dynamicdynamic24 KB2.0 KB0.6 KB
amberramp only1.8 KB0.3 KB0.1 KB
yellowramp only1.9 KB0.3 KB0.2 KB
limeramp only1.8 KB0.3 KB0.2 KB
emeraldramp only2.0 KB0.3 KB0.2 KB
indigoramp only1.9 KB0.3 KB0.1 KB
roseramp only1.8 KB0.3 KB0.2 KB
oliveramp only1.8 KB0.3 KB0.3 KB

A ramp-only family (amber … olive) is just its token declarations, about 2 KB. A tone family costs 13× that, and the extra isn't tokens:

One tone family (teal), broken down

One tone family (teal), broken down
Where teal's bytes goMinified
Token declarations (light + dark)4.6 KB
`.color-palette_teal` (plain utility)4.0 KB
`[data-item-tone='teal']` colorPalette copy4.0 KB
`[data-list-tone='teal']` colorPalette copy4.0 KB
Radio tab-thumb `:has([data-tone='teal'])` copies8.7 KB
`c_teal.text.accent` utilities0.2 KB
scenarioWhat the end user downloads

Consumer apps that import only a few components: before this work, after the fixes alone, and with each plugin option. gzip (minified).

CSS per consumer app (Astro components, designSystem config)

CSS per consumer app (Astro components, designSystem config)
AppImportsBeforeFixes, no pluginpindoba()families: []+ raster: falsegzip
No components(the base every app pays)115 KB (1,168 KB)98 KB (943 KB)24 KB (189 KB)12 KB (92 KB)12 KB (85 KB)-90%
Landing pagebutton, badge, card, input115 KB (1,168 KB)98 KB (943 KB)38 KB (338 KB)27 KB (241 KB)26 KB (234 KB)-78%
Formbutton, input, select, checkbox, radio, field, textarea, dialog115 KB (1,168 KB)98 KB (943 KB)61 KB (535 KB)49 KB (438 KB)48 KB (430 KB)-58%
Dashboardbutton, input, select, checkbox, table, navigation, tab, menu, tooltip, popover, calendar, badge, card, dialog115 KB (1,168 KB)98 KB (943 KB)67 KB (592 KB)54 KB (494 KB)54 KB (487 KB)-53%
Topbar alonetopbar (pulls in the appearance picker)115 KB (1,168 KB)98 KB (943 KB)68 KB (609 KB)56 KB (512 KB)56 KB (505 KB)-52%

Panda's optimize.treeshakeDesignSystem keeps only the recipes whose *Styles export the app imports, and it only counts import { buttonStyles } from "@pindoba/styled-system" written in the app's own files. Our apps import @pindoba/astro-button; only core-button imports buttonStyles. With the option on as is, docs pages lose the CSS of 70% of their styled classes.

The fix is a Panda plugin. Its parser:before hook adds the missing import to each app file that imports a @pindoba/* package, listing every *Styles that package reaches through its dependencies. The import goes into the frontmatter of an .astro file or the <script> of a .svelte/.vue file, because Panda reads imports only there. On the docs, which render every component, it keeps the CSS for every class except 21 native-select ones. Those could never be kept: styles-select-native and styles-select both exported selectStyles, and Panda's export map keys on the name. It is now selectNativeStyles, and the styles-map build fails if two modules ever collide again.

The base every app pays: 115 KB → 12 KB gzip The topbar alone costs as much as the dashboard: its appearance picker pulls in color-picker, choice, field and popover
scenarioWhat Panda's own options do for us

Every size lever Panda 2.2 offers, measured on our CSS.

Panda 2.2 size options against pindoba

Panda 2.2 size options against pindoba
Panda optionWhat it doesMeasured on pindobaVerdict
optimize.treeshakeDesignSystemEmit only the design-system CSS for exports the app importsKeeps nothing for us as is: it only counts `import { xStyles } from "@pindoba/styled-system"` in the app's own files, and our apps import UI packagesFix first
optimize.removeUnusedTokensDrop token declarations nothing uses−36 KB, −4.5 KB gzip, but it deletes the ramp-only families and chart.* that core-tone, devtools and chart code read by name at runtimeSkip
optimize.smartCompoundVariantsEmit only the compound-variant combinations used0 bytes: our recipes are atomic sva(), not config recipesSkip
optimize.removeUnusedKeyframesDrop keyframes nothing references−150 bytesUse
cssgen --splittingOne file per layer and recipe, load only what a page rendersDoesn't split us: everything sits in the utilities layer, the recipes layer is 64 bytesSkip
theme.colorPalette include/excludeChoose which colors get colorPalette classesWhole families only; can't drop the 33 never-read numbered stepsSkip
layers: { recipes: "utilities" }Rename a cascade layerTried, to put config recipes level with utilities: Panda warns layer_name_collision and nests recipes in sub-layers, which still lose to the utilitiesSkip
hashShorter class names and variablesSmaller files, not smaller downloads (see 02)Skip
scenarioWhy the full CSS stayed near 1 MB

What the docs CSS (every component) was still made of after the fixes, and what moved Loading.

581 KB

of selectors, against 451 KB of declarations

682

rules with a selector over 200 characters (339 KB), from only 381 conditions

167 KB

what those would take written once per condition (estimate)

Panda writes component styles as atomic CSS: one tiny rule per declaration, shared by every component that uses it. For a short condition (_hover) that is compact. For a long one, each declaration repeats the whole condition twice — escaped in the class name and again as the selector. A config recipe writes one rule per slot and variant instead:

/* atomic (sva): one rule per declaration */
.d_flex { display: flex }
.px_sm { padding-inline: var(--spacing-sm) }

/* config recipe: one rule per slot and variant */
.button__root { display: flex; padding-inline: var(--spacing-sm) }

Loading's treatment, before and after

Loading's treatment, before and after
Loading's treatmentRulesMinifiedWhy
Loading treatment, atomic (sva)155146 KB31 conditions, each repeated per declaration; the longest rule is 8.6 KB
Loading treatment, config recipe3418 KBeach condition written once, short class names, !important

Config recipes are emitted in Panda's recipes cascade layer, below utilities, where every component's atomic styles live — so a button's own background would beat the skeleton. Every treatment declaration is !important to cover them (it now also covers inline style=). The treatment still ships only with Loading: styles-loading calls each variant with literals, and panda lib records them in that module's build info.

Everything else stays atomic. 77 connects build their classes from xStyles.raw() merged by css() property by property — what passThrough and the 44 cores built on connectPanel rely on — and a config recipe is a finished class that can't be merged that way. Config recipes are for components whose CSS is dominated by long conditions.

scenarioWhat we did

Each recommendation below, shipped or skipped, and why.

Outcome of each recommendation

Outcome of each recommendation
ChangeStatusDetail
Panda plugin: tree-shake the design systemDone`@pindoba/styled-system/plugin`. Adds the `*Styles` imports Panda's treeshakeDesignSystem needs from a styles map built with `panda lib`; the build fails when a style module can't be selected.
Rename select-native's `selectStyles`DoneNow `selectNativeStyles`; it collided with select's, so tree-shaking could never keep it.
`families`: ship only the color families an app usesDoneDrops tokens, palette classes and chart slots of unlisted families, keeping any family the kept tokens reference.
One palette class per toneDoneListBox and List rows take the shared `color-palette_<tone>` class from their connect; Group's tab bar re-points one color var. Palette rules 76 → 21.
Concentric corners computed in placeDoneThe 220 `radii.inner.*` tokens (declared 3× for density/radius scopes, 59 KB) are gone; recipes write the formula, consumers use the `innerRadius` utility.
Popover z-index without the token mapDoneA literal pinned to the token by a test; no more 80 KB `token()` map.
`raster: false`DoneGlobal CSS can't be tree-shaken (Panda's cssgen:done hook can't rewrite CSS), so apps without a Grid opt out of its 24 KB.
Loading's treatment as a config recipeDoneAtomic CSS wrote each long skeleton condition once per declaration: 155 rules, 149 KB. As the `loadingTreatment` config recipe it is 34 rules, 18 KB. Its declarations are `!important` because config recipes sit below the utilities layer; it still ships only with Loading.
`light-dark()` for mode-flipping colorsSkippedWith `families: []` the whole light block is 1.4 KB gzip; not worth dropping Safari < 17.5 and rewriting every runtime that writes per-mode vars.
Move the numbered steps to `ramp.*`SkippedKept as public API; the palette classes still carry 33 unread steps.

The docs CSS, which renders every component, went from 1,263 KB to 1,077 KB minified with the palette and corner fixes, and to 901 KB once Loading's treatment became a config recipe (127 KB → 107 KB gzip). Most of what remains is atomic CSS for short conditions, which is already compact. Consumer setup is in Shipping less CSS.

scenarioThe original recommendations

As written before the work, ranked by bytes saved per unit of risk.

Tree-shake the design system for consumers

Ship a Panda plugin (parser:before) that adds import { xStyles } from "@pindoba/styled-system" to every app file importing a @pindoba/* package, for the styles that package reaches through its dependencies, and turn on optimize.treeshakeDesignSystem. A landing page drops from 115 KB to 38 KB gzip. Rename select-native's selectStyles first: it collides with select's and can never be kept.

Leave hash off

Compressed, it saves at most 8% (gzip, class names only) and makes brotli bigger once vars are hashed. It also breaks 62 token vars read by hand-written var() in the CSS, every runtime that writes --colors-* by name (core-tone, devtools, panel ramps), and every consumer, who must build with the same hash.

Re-point only the palette tokens components read

Each colorPalette rule restates 63 virtual tokens; only 30 are ever read. Dropping the numbered ramp re-points (50–950, light.*, dark.*) removes 74 KB minified, 6.3 KB gzip.

One colorPalette class per tone, not one per condition

A tone family pays for its colorPalette block 5 times: the plain class, [data-item-tone], [data-list-tone] and two Radio tab-thumb :has() copies. Baking the plain color-palette_<tone> class into the bag from the connect would make each family ~8 KB instead of ~26 KB.

Stop shipping the token map for one z-index

@pindoba/core-popover's z-index.ts calls token("zIndex.popover"), which bundles the whole token map (78 KB, 11 KB gzip, 1,209 color entries) into every app with a popover, select, menu or tooltip. A constant (pinned by a test against the token) removes it.