guides
source transformation

Source Transformation

Fold static css() and pattern calls into literal class strings at build time

Bamboo resolves styles at runtime: css({ color: 'red.300' }) runs on every render and returns a class string. The result is cached, but the call still costs something — and for a call whose arguments never change, that cost buys nothing.

Source transformation removes it. During a production build, @bamboocss/vite rewrites statically-resolvable calls into the string they would have returned:

// you write
export const title = css({ fontSize: 'lg', fontWeight: 'bold' })
 
// the bundle gets
export const title = 'fs_lg fw_bold'

The CSS is unchanged. Only the JavaScript changes.

Setup

Install the plugin and add it to your Vite config. It is off by default — you have to ask for it.

pnpm add -D @bamboocss/vite
// vite.config.ts
import { defineConfig } from 'vite'
import bamboocss from '@bamboocss/vite'
 
export default defineConfig({
  plugins: [bamboocss({ transform: true })],
})

This plugin does not emit CSS. Keep your existing PostCSS setup — it stays responsible for the stylesheet. The plugin's only job is the fold.

What folds

A call folds when every argument can be resolved at build time and the call evaluates to a class string.

// ✅ literal styles
css({ color: 'red.300', padding: '4' })
 
// ✅ conditions, responsive values, conditional value maps
css({ color: 'red.300', _hover: { color: 'blue.500' } })
css({ fontSize: { base: 'sm', md: 'lg' } })
 
// ✅ several arguments — merged later-wins first, exactly as the runtime merges them
css({ color: 'red.300', padding: '2' }, { color: 'blue.500' })
 
// ✅ pattern calls
stack({ gap: '4', align: 'center' })
 
// ✅ config recipe calls, default and compound variants included
buttonStyle({ size: 'sm' })
 
// ✅ inside a JSX expression, which is still a call site
<div className={css({ px: '4' })} />

JSX elements

A styled.* element collapses to the intrinsic tag it renders:

// you write
<styled.div color="red.300" onClick={fn}>hi</styled.div>
 
// the bundle gets
<div onClick={fn} className={"c_red.300"}>hi</div>

This is the one that matters most at runtime. The factory runs splitProps, css() and cx for every element on every render, inside a forwardRef component — folding removes all of it, including the component layer.

Measured on sandbox/runtime-perf, rendering fifty trees, warm: 3.2× faster on a mixed tree, and 2.9× on one whose style props are all runtime-valued. The second is the weaker case and still the more interesting one — nothing about those elements resolves at build time, so what folding removes there is the factory itself rather than the styles. Both sides are warmed first, which is the conservative reading: the runtime's style memo is full, so this is the steady-state win rather than the cold-start one, and cold start is where the gap is widest.

Props are handled the way the factory handles them. For a factory with no recipe attached, defaultShouldForwardProp reduces to "css properties are consumed, everything else reaches the DOM unchanged", so onClick and id pass through verbatim while color becomes part of the class. A static className is appended, in the position cx would have put it.

A static as names the tag to fold to, rather than blocking the fold:

<styled.div as="section" color="red.300">hi</styled.div>
<section className={"c_red.300"}>hi</section>

It works for a component too — as={Link} folds to <Link className={…}>. splitProps keys off the factory's own config rather than off what as points at, so the class and the forwarded props are the same whatever the tag becomes. An as only known at runtime still bails.

An element bails if it carries anything else the factory gives extra meaning to: a spread, a dynamic style prop, unstyled, css, ref, key, an explicit children, a dynamic className, or one of the html* props that normalizeHTMLProps renames.

Pattern elements

<Stack>, <Box>, <HStack> and the rest fold too, and they save more than a styled.* element does. A pattern component is a second layer: it splits its own props out, runs them through the pattern's transform, and hands the result to styled.<jsxElement>, which then does everything above. Folding collapses both layers at once.

<Stack gap="sm" id="x">hi</Stack>
<div id="x" className={"d_flex flex-d_column gap_sm"}>hi</div>

Pattern props and style props are both consumed; anything else passes through. as and a static className behave as they do on a styled.* element.

Pattern elements only fold under the default jsxStyleProps: 'all'. Under minimal and none the pattern's styles reach the factory through the css prop rather than being spread, which reverses which side wins when a prop is set in both places.

jsx: false turns element folding off altogether — both styled.* and pattern elements — leaving call sites folding on their own.

What does not fold

Everything else is left byte-identical. A call Bamboo cannot fully resolve keeps its runtime behaviour.

// ❌ a value only known at runtime
css({ color: tone })
 
// ❌ a spread of anything but an inline object literal
css({ color: 'red.300', ...rest })
 
// ❌ a computed key
css({ [key]: { color: 'red.300' } })
 
// ❌ several arguments where one is dynamic — later-wins across the whole object, so the
//    static half cannot be hoisted out without reproducing the merge
css({ display: 'block' }, extra)

Composed across files

A value imported from another module folds, as long as the extractor can resolve it statically:

// styles.ts
export const button = css.raw({ display: 'inline-flex', padding: '4' })
 
// button.tsx
import { button } from './styles'
export const cls = css(button, { background: 'blue.500' })
// → "d_inline-flex p_4 bg_blue.500"

The same holds for plain exported objects, aliased imports, re-exports, and pure local helpers — including IIFEs:

const pad = (n) => ({ padding: n })
css(pad('4')) // → "p_4"

When a fold reads from another module, the plugin registers that module as a watch dependency, so editing it re-transforms the files that folded against it rather than leaving a stale literal behind.

The one cross-file shape that does not fold is an imported value spread inside a nested selector (css({ '& svg': { ...icon } })). Extraction handles it and the CSS is still emitted — only the rewrite is declined, for the spread reason below.

Never folded

Three kinds of call never fold, because they do not evaluate to a class string at all:

  • css.raw(), recipe.raw(), and pattern.raw() return a style object so callers can compose them. Folding one to a string would break every consumer.
  • cva() and sva() return a function. Their definitions stay as they are.
  • token() returns a token value, not a class.

Why spreads are conservative

{ ...base } where base is a static local object is resolvable, and Bamboo's extractor resolves it. But once the extractor flattens a spread into its result, a spread it understood and a spread it silently skipped look identical — both simply contribute keys, or fail to. Folding the second case would drop styles without any error.

Rather than guess, the fold declines any spread that is not an inline object literal.

Partly static calls and elements

A single-argument css() call, or a styled.* element, does not have to be entirely static. The part that resolves becomes a literal and the rest keeps its runtime call:

css({ color: 'red.300', padding: p })
cx('c_red.300', css({ padding: p }))
 
<styled.div color="red.300" backgroundColor={tone} />
<div className={cx('c_red.300', css({ backgroundColor: tone }))} />

The element still collapses to its intrinsic tag, so the factory goes with it.

A top-level ternary is neither half. Both its branches are known, so each resolves now and the choice becomes a ternary between two literals:

css({ margin: '2', color: isError ? 'red.500' : 'green.500' })
cx('m_2', isError ? 'c_red.500' : 'c_green.500')

Independent conditionals stay linear — two of them give two ternaries, not four combinations. One branch that does not resolve makes the choice open-ended again, and the property goes back to the runtime. The ternary has to be written at the call site: one reached through a variable stays where it was declared, so its condition is neither copied into a scope that cannot see it nor re-evaluated on every call.

A value that resolves to nothing at all still lowers, because the class is the property's prefix plus whatever the value holds — and the prefix is known now:

css({ margin: '2', color: tone })
cx('m_2', cssLeaf('c_', 'color', tone))

This changes nothing about which classes have CSS behind them: css() already built the class from the value alone, so a value the extractor never saw already produced a class with no rule. A responsive array, a condition object and any non-scalar fall back to css() at runtime, and null produces no class. It applies to a top-level property; hash and cssMode: 'grouped' decline it, since neither appends the value to a prefix.

Splitting is refused when two halves could produce a class for the same property, since cx concatenates without resolving conflicts: a shorthand facing its longhand, a top-level base block, and multi-argument css(). A property counts as static only if the extractor resolved every leaf and accounted for everything the source declared, so a dynamic array element, a nested dynamic value and a spread all keep their whole property at runtime. A ternary nested inside a condition block stays there too — lowering it would mean carrying the condition's path into each branch.

Element splitting needs both css and cx, so a file that does not already import css is left alone. Turn splitting off with partial: false.

Knowing whether a call folded

A build prints a coverage summary when it finishes:

Folded 184/213 (86%) across 41/58 files — declined: dynamic=22 raw-call=5 not-imported=2

That is the number to watch. It tells you the transform ran, how much of the project it reached, and what the remainder is waiting on — a project sitting at 40% dynamic is a different problem from one sitting at 40% not-imported. Turn it off with reportSummary: false.

For per-file detail, turn on reportSkipped to have every declined call reported with a reason:

bamboocss({ transform: true, reportSkipped: true })
src/Button.tsx: dynamic=2 raw-call=1

The reasons are:

ReasonMeaning
dynamicSome part of the arguments could not be resolved at build time.
raw-callA .raw() call, which must keep returning a style object.
not-foldablecva, sva, or token — cannot evaluate to a class string.
unsupported-kindA slot recipe call, or a shape this phase does not handle.
not-importedThe callee is not a Bamboo import (see below).
emptyResolved, but produced no class names.
overlappingNested inside another folded call.
no-call-expressionThe call site could not be located in the source.

not-foldable and unsupported-kind are deliberately separate. The first is permanent — cva and sva return functions and token returns a value, so there is no class string to fold to. The second is a limitation of this phase, such as a slot recipe, which resolves to one class per slot rather than to a single string.

You can also just read the output bundle: a folded call is a string literal, an unfolded one still says css(.

not-imported, specifically

Bamboo's extractor matches style calls by name and does not require an import — a deliberate choice, since the worst case for CSS extraction is a handful of unused rules. A source transform cannot be that relaxed: your own const css = (styles) => JSON.stringify(styles) would otherwise be rewritten into a class string.

So the fold asks for more than the extractor does. The callee's name must be imported at file level, and no enclosing scope may shadow it. A call that merely looks like a Bamboo call is reported as not-imported and left alone.

Build only, and why

The plugin declares apply: 'build'. It does not run in vite dev.

Folding a module means re-parsing it with ts-morph. In a build that cost is paid once per module and amortizes across the whole pass. Measured on sandbox/vite-ts, it is roughly 0.3 ms for a small component and 3 ms for a 147-line file with 24 call sites — the parse dominates, and the fold itself adds about 10% on top of it. For a 500-module app that is somewhere under a second and a half added to a production build.

In dev the trade is bad in both directions: the same parse lands on every hot update, feeding a source file back into the project invalidates cross-file resolutions that other modules memoized, and a dev bundle gains nothing from having its style calls pre-resolved. So the transform stays where it pays for itself.

What it does not do

  • It does not remove the runtime from your bundle. Dropping createCss and the utility tables requires every call site in the module graph to fold, which realistically does not happen in an app with dynamic components. You get the per-call CPU saving; the runtime still ships. Bundle size barely moves either way, and gzipped it moves slightly against you — on sandbox/runtime-perf, -0.8% raw and +1.0% gzipped, because class literals are all distinct where the repeated css({ … }) calls they replace compressed almost to nothing.
  • It does not remove the now-unused import. import { css } from 'styled-system/css' stays after its last call folds. Bundlers tree-shake it.
  • It does not fold recipe elements. A config recipe's jsx components — <Button size="sm" /> — keep their runtime path, even though the equivalent buttonStyle({ size: 'sm' }) call folds. styled.* and pattern elements are the two element surfaces that fold. A recipe element is not counted in the coverage summary either, so a project using them reads as having fewer sites than it has.
  • It does not fold slot recipe calls. One resolves to a class per slot rather than to a single string, so there is no single literal to substitute. Reported as unsupported-kind.
  • It only sees JSX written as JSX. Element folding matches tags in the source, so an element already compiled to _jsx(...) keeps its runtime path. A css() call inside that output is still a call site and does fold.

How it stays correct

The folded string is computed through the same runtime css your app would have called, rebuilt in-process from your resolved config — so the substitution is behaviour-preserving by construction rather than by a reimplementation that could drift.

That leaves one thing to verify, and the test suite verifies it: that every class in a folded string is backed by a rule in the CSS the build emits. packages/vite/__tests__ checks this across conditions, responsive values, !important, multiline values, arbitrary values containing quotes and backslashes, shorthand and longhand conflicts, and multi-argument merge order — plus a parity test asserting the emitted CSS is byte-identical whether or not folding ran.