Source compilation
How Bamboo compiles style APIs to shared atoms and rejects runtime styling.
The Vite integration is a compiler, not an optional optimization. It runs in development and production, and Bamboo style APIs in application source are compile-time syntax. There is no switch that restores runtime styling.
// source
export const title = css({ display: 'flex', color: 'red.300' })
// transformed module
export const title = 'd_flex c_red.300'
The virtual stylesheet contains the two shared declaration atoms. A second css() call or any recipe selecting either
declaration reuses the same class and rule.
Why compilation is mandatory
A fallback model has two expensive properties:
- one unresolved call retains the generated style engine and its lookup tables — and generated
css()/cva()now throw if they run at all, so a missed fold cannot silently ship that engine; - recipe-specific classes prevent declarations such as
display: flexfrom being shared with other recipes orcss()calls.
Bamboo instead gives the build one contract: every style-producing call must be compiled, or the build fails. This makes JavaScript removal, global CSS deduplication, and graph-based CSS pruning reliable rather than best-effort.
The compiled representation
Recipe identity is not style identity. The compiler resolves a recipe selection to authored declarations, merges them in recipe order, and only then allocates global atoms.
const row = cva({ base: { display: 'flex' } })
export const fromRecipe = row()
export const fromCss = css({ display: 'flex' })
Both values contain the same class for display: flex, and the stylesheet contains that rule once. The source filename,
recipe variable, configured recipe name, slot name, and call site do not enter the declaration identity.
The production CSS emitter:
- converts observed recipe declarations to utility atoms;
- omits the recipe layer and all named recipe rules;
- keeps explicit
staticCssatoms as a safelist; - removes source-graph atoms no transformed module can emit;
- names classes using the configured
hashsetting; and - hashes the final CSS bytes after pruning.
Development uses the same atoms and naming setting. Names are readable by default; hash: true hashes them in every
mode, while hash: 'auto' keeps them readable in development and hashes them in production. Development keeps the full
extracted atom set because Vite loads modules lazily, and hot-replaces the virtual stylesheet when source changes.
What compiles
css() and patterns
All arguments and style values must be statically recoverable. Multiple arguments are merged before atoms are allocated,
so later values preserve normal css(a, b) precedence.
css({ color: 'red.300' }, { color: 'blue.500', display: 'flex' })
// emits only the blue color atom and the display atom
An open runtime style value has no finite CSS rule set and is rejected:
// rejected: `tone` can be any CSS value
const className = css({ color: tone })
Express runtime choices as declared recipe variants, or make a finite choice explicit in source. Use staticCss when
classes are constructed outside Bamboo's analyzable source graph. A value with no finite set of possibilities — a
progress width, a dragged offset — has no rule a build could emit for it, so it belongs in the style attribute — or in
a CSS variable the compiled rule reads, where the value has to participate in the cascade: see
dynamic styling.
css.raw() deliberately does not compile to a class: it returns a style object. A live use of it fails the strict
compiler contract because it retains the style runtime.
Inline cva() and sva() recipes
The recipe declaration is extracted and erased from the transformed module. Static selections resolve directly to shared atoms:
const badge = cva({
base: { display: 'flex' },
variants: {
tone: {
quiet: { color: 'gray.500' },
loud: { color: 'red.500' },
},
},
})
badge({ tone: 'quiet' })
When a declared variant axis is chosen at runtime, Bamboo enumerates its finite state space at build time. Each leaf is
a complete, precedence-correct StyleSet, and a small cvaMap decision table selects among those precompiled class
strings. The style config and recipe engine do not ship.
export const className = (tone) => badge({ tone })
// becomes a cvaMap lookup whose leaves are complete shared class strings
undefined remains distinct from null and unknown values so defaults behave correctly. Compound variants are resolved
in every leaf. Slot recipes can return either one selected slot or a precompiled slot object.
The Cartesian product is bounded by maxRecipeStates (65,536 by default). Make axes static, split an unusually large
recipe, or raise the bound deliberately when a recipe exceeds it.
Spreads, computed selection keys, unresolved recipe configs, and reflective reads such as recipe.config are rejected.
recipe.splitVariantProps(props) is compiled to the generated splitProps helper and does not retain the recipe
object.
What actually decides whether an inline recipe compiles
Not where it is declared, and not whether its variants are chosen at runtime — both of those compile, in the declaring module and across module boundaries alike. What decides it is whether every reference to the binding is a call the compiler rewrote.
The declaration is erased, so the binding's value becomes undefined. A reference that is not a compiled call therefore
has nothing behind it, and the build fails with runtime-binding rather than shipping a value that would be undefined
at runtime:
export const badge = cva({ ... })
badge({ tone }) // compiles — a call, rewritten to a cvaMap lookup
export const alias = badge // runtime-binding — the value itself is read
badge.raw({ tone: 'loud' }) // runtime-binding — returns a style object, not a class
Exporting is fine, and so is calling the recipe from another module — that call compiles like any other. Each module answers only for its own text, so a module that reads the binding reports itself, and one that only calls it reports nothing. The diagnostic names the file and line of the read, which is what has to change, not the declaration.
Config recipes
Config recipes use the same lowering as inline recipes. Static selections become literal atom strings; scalar variant
props become a finite cvaMap decision table; config slot recipes become precompiled slot objects. Responsive or
conditional values belong inside the recipe's style declarations, not in a runtime variant selection.
cx()
When every Bamboo style argument and external class literal is statically analyzable, cx() is resolved semantically:
the compiler merges the StyleSets before allocating classes, removes overridden declarations, and preserves literal
third-party classes.
cx(css({ color: 'red.300' }), css({ color: 'blue.500' }))
// contains only the blue declaration atom
For a finite dynamic recipe plus static Bamboo styles, the static styles are composed into every decision-table leaf. A
cx() whose arbitrary class inputs are runtime values remains the tiny string-joining helper; Bamboo makes no
conflict-resolution guarantee for that shape.
Tokens
token() and token.value() calls with a statically known path compile to string literals. Unknown paths and dynamic
paths fail rather than retaining the complete runtime token dictionary.
Not inside a recipe config. A token() call in a cva/sva config is not resolved, which leaves the recipe
declaration standing and reports the cva import as runtime-binding at the declaration — a diagnostic that names
neither the config nor the call. Use the string form, which the utility resolves and the compiler accepts:
cva({ base: { outline: '2px solid token(colors.accent)' } }) // ✅
cva({ base: { outline: `2px solid ${token('colors.accent')}` } }) // ❌ runtime-bindingA token() call inside css() is unaffected.
View transitions
A static viewTransition() bag compiles to its compact class literal. The CSS optimizer treats its carrier selector,
::view-transition-* selectors, and view-transition-class declaration as one renamable, prunable unit. A dynamic bag
is rejected because its runtime hash would name CSS the build could not emit.
Compiler errors
The compiler scans the output plan as well as recognized calls. A Bamboo import passed around, re-exported, used reflectively, or missed because a transform threw is still a survivor and fails the build.
bamboocss: 2 call(s) could not be compiled.
src/card.tsx
18: css() — dynamic
src/styles.ts
7: badge — runtime-binding
Common reasons:
| reason | meaning |
|---|---|
dynamic | The call contains an open value or source shape the compiler cannot prove. |
recipe-call | An inline recipe selection is not a finite analyzable shape. |
raw-call | The API returns a style object rather than a class. |
unsupported-kind | The result shape cannot be represented as the requested class value. |
runtime-binding | A Bamboo runtime binding remains referenced after planned rewrites. |
compile-failed | Compilation of the module threw; no clean result can be claimed. |
not-imported is not an error: it means a local or third-party function happened to use the same name. An overlapping
inner candidate is owned by the enclosing compiled expression. Neither is opaque-composition, which is a cx() that
joins classes the compiler resolved with one it cannot read — a component forwarding a className prop, usually. It is
separated from dynamic so per-file diagnostics can name it, and it never fails the build; see
classname concatenation for what it does and does not
guarantee.
Use reportSkipped: true for per-file diagnostics. The summary is enabled by default.
Required graph agreement
Import virtual:bamboo.css exactly once. A build fails if compiled classes exist without that virtual stylesheet, or if
a transformed source module is outside Bamboo's configured include graph. Both cases would otherwise produce class
values with no backing rules.
The compiler emits the layer order itself. Because named recipe rules are absent, the order is
reset, base, tokens, utilities.
See Using Vite for the remaining tuning and diagnostic options.