guides
source transformation

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: flex from being shared with other recipes or css() 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:

  1. converts observed recipe declarations to utility atoms;
  2. omits the recipe layer and all named recipe rules;
  3. keeps explicit staticCss atoms as a safelist;
  4. removes source-graph atoms no transformed module can emit;
  5. names classes using the configured hash setting; and
  6. 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-binding

A 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:

reasonmeaning
dynamicThe call contains an open value or source shape the compiler cannot prove.
recipe-callAn inline recipe selection is not a finite analyzable shape.
raw-callThe API returns a style object rather than a class.
unsupported-kindThe result shape cannot be represented as the requested class value.
runtime-bindingA Bamboo runtime binding remains referenced after planned rewrites.
compile-failedCompilation 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.