Using Vite
Easily use Bamboo with Vite, React and Typescript with our dedicated integration.
Build platform requirements
Bamboo extracts styles with a required Rust/Oxc native binary bundled in @bamboocss/node. Published packages include
prebuilt binaries for these machines running the CLI, dev server, or production build:
| Operating system | Architectures |
|---|---|
| macOS | arm64, x64 |
| Linux with glibc | arm64, x64 |
| Windows | x64 |
Installing the published packages does not require Rust. Alpine Linux/musl and Windows arm64 have no bundled binary; use a supported build environment. There is no JavaScript or TypeScript extraction fallback. These requirements apply to build machines; browsers receive the compiled JavaScript and CSS.
For a missing or unloadable binary, see native extraction errors.
Start a new project
Create Vite project
To get started, we will need to create a new Vite project using react-ts template.
Install Bamboo
Install bamboo and the Vite plugin, then create your bamboo.config.ts file.
Update package.json scripts
Add "prepare": "bamboo codegen" to scripts. It runs codegen after every dependency
install, so the output directory can stay in .gitignore.
The build itself no longer needs it. The Vite plugin generates styled-system/ in buildStart, before anything
resolves an import of it, so a fresh clone builds without a separate codegen step. Keep the script regardless: tsc and
your editor read the generated types without ever running the plugin.
Configure the content
Make sure that all of the paths of your React components are included in the include section of the bamboo.config.ts
file.
bamboo.config.ts
import { defineConfig } from '@bamboocss/dev'
export default defineConfig({
// Whether to use css reset
preflight: true,
// Where to look for your css declarations
include: ['./src/**/*.{js,jsx,ts,tsx}', './pages/**/*.{js,jsx,ts,tsx}'],
// Files to exclude
exclude: [],
// The output directory for your css system
outdir: 'styled-system',
})Add the plugin to your Vite config
vite.config.ts
import { defineConfig } from 'vite'
import bamboocss from '@bamboocss/vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [bamboocss(), react()],
})Import the virtual stylesheet
src/main.tsx
import 'virtual:bamboo.css'Reference the ambient declaration so TypeScript accepts that import:
src/vite-env.d.ts
/// <reference types="vite/client" />
/// <reference types="@bamboocss/vite/client" />That is the whole setup ā no postcss.config.cjs entry for Bamboo, and no file carrying the @layer statement, since
the virtual module emits its own. You still need PostCSS for anything else you run through it, autoprefixer included.
In development the stylesheet is served from memory and hot-replaced in place, so editing a style repaints without a
reload or a loss of component state. Editing bamboo.config.ts, or a preset it imports, restarts the server instead ā
Bamboo declares it to Vite as a config file. A config edit can change what compiles and not only what a value resolves
to, so everything is rebuilt rather than half of it. In a production build the stylesheet is hashed into the asset graph
like any other CSS.
Note: Feel free to remove src/App.css file as we don't need it anymore, and make sure to remove the import from
the src/App.tsx file.
Start your build process
Start the dev server with pnpm dev.
Start using Bamboo
src/App.tsx
import { css } from '../styled-system/css'
function App() {
return <div className={css({ fontSize: '2xl', fontWeight: 'bold' })}>Hello š!</div>
}
export default AppPlugin options
bamboocss() compiles Bamboo style calls in development and production, and emits their globally shared declaration
atoms through the virtual stylesheet:
// you write
export const title = css({ fontSize: 'lg', fontWeight: 'bold' })
// the bundle gets
export const title = 'fs_lg fw_bold'
| option | default | what it does |
|---|---|---|
maxRecipeStates | 65536 | Bound exact build-time enumeration for runtime recipe axes. |
reportSummary | true | Print a compiler coverage summary when a build finishes. |
reportSkipped | false | Report every call site the compiler rejected, and why, per file. |
configPath | ā | Path to bamboo.config.ts, resolved the same way as the CLI. |
cwd | ā | Directory the config is resolved from. |
pruneCss | true | Remove rules for atoms no compiled module can emit. See below. |
splitCss | true | Give each lazily loaded chunk a sheet of the atoms only it uses. |
pruneCss drops every rule the source graph produced that nothing in the bundle can reach. It applies to builds only ā
the dev server never prunes. Turning it off ships the whole extracted stylesheet: larger, and never wrong by pruning.
It also stands down the assertion that every compiled class has a rule, since that check exists to catch this pass
removing too much, so pruneCss: false is a true escape hatch ā it cannot fail a build over reachability.
The pruned stylesheet is also renamed to a hash of its own bytes, and that is deliberately not a setting of its own.
[hash] is expanded before Bamboo prunes, so the name Vite assigned describes the sheet as it was before pruning.
Leaving that name on pruned bytes is how a stale stylesheet outlives a deploy ā a change to reachability alone, which is
what upgrading Bamboo is, leaves identical source CSS under an identical name with different content, and a CDN holding
that key keeps serving the old one. So the bytes and the name move together, or neither does.
Reach for pruneCss: false if something downstream derives an artifact from the stylesheet's content during
generateBundle before Bamboo runs, or to rule pruning out while diagnosing a missing rule. Subresource integrity is
the clear case: an integrity attribute is a digest of the bytes, so no amount of reference rewriting can carry it
across an edit, and a browser handed a stale digest refuses the stylesheet outright. Note that this is a hazard of the
pruning, not of the rename ā Bamboo rewrites the references it can see, and no rename happens unless the bytes already
moved.
Where the consumer can run after Bamboo instead ā order: 'post' listed later, or writeBundle/closeBundle ā prefer
that and keep the pruning. The build prints one line whenever pruning is off, so it is never off silently.
What the compiler transforms
The compiler folds the modules include covers and exclude does not, which is also what the stylesheet is extracted
from, so a compiled class always has a rule behind it. A module outside that inventory is left alone, with three
exceptions: one an included file imports a recipe from, one whose text names a bamboo entrypoint, since the build's
check for classes without rules exists to report exactly that misconfiguration, and one importing a module the project
holds by relative path, which may be calling a recipe declared there. Excluding generated code, as in
exclude: ['**/__generated__/**'], therefore also keeps it out of the compiler, which matters at scale: with the
TypeScript 7 backend, every module the compiler parses outside the inventory costs a reload of the whole project.
Per-route stylesheets
A utility only one lazily loaded chunk uses is written into a stylesheet of that chunk's own, attached where Vite's own
plumbing reads it: the manifest lists it under the chunk, and the preload helper fetches it before the chunk runs. A
utility two chunks use, one the entry or its static imports reach, or one staticCss asked for, stays in the entry
sheet, so nothing is ever downloaded twice and a route that is the only user of a style downloads that style with the
route.
The split is safe because precedence does not depend on where a rule sits: it lives in the cascade sublayers, and every chunk sheet opens with the same sublayer order statement as the entry sheet, so whichever the document parses first establishes the same order.
Builds only, and only where Vite's build.cssCodeSplit is on, which it is unless you turned it off or are building a
library. A framework that copies a server build's stylesheets into the client build by the names its chunks recorded, as
the RSC plugin does, copies the chunk sheets along with the entry sheet. Set splitCss: false to keep one sheet.
Source maps in development
With Vite's own css.devSourcemap on, the dev server serves the stylesheet with a source map from each rule to the call
that first wrote its atom, so DevTools names the file and line beside the rule instead of the virtual stylesheet:
vite.config.ts
export default defineConfig({
css: { devSourcemap: true },
plugins: [bamboocss()],
})A rule that several calls share points at the first of them, in path order across files and in source order within one,
so the answer is the same however files were read. An inline cva() or sva() attributes its rules to the call that
declared it. Rules from a config recipe, from staticCss or from globalCss have no call site and are left
unattributed, as is a file a parser:before hook rewrote, whose positions are the hook's rather than the file's.
Off, which is Vite's default, nothing is recorded during extraction and the served sheet is what it was. Builds are unaffected either way.
Builds with more than one environment
An SSR framework builds a client bundle and a server bundle, and the stylesheet is emitted by the one that imports it,
usually the client, which finishes before the server environment has compiled anything. Reachability is only whole once
every environment has contributed. So a run that announces its environments ā builder in the Vite config, which every
framework building more than one sets ā prunes the sheet against what it knows so far, writes it under a name hashed
from those bytes, and prunes it again from its unpruned source once the last environment has written its output. Usually
that produces the bytes already on disk, and nothing moves. When a later environment restored a rule, the final bytes go
under a new name and every reference to it is rewritten in place: the HTML, the manifest, a server bundle that embeds
the client's asset names, and any copy of the sheet a framework wrote into another output.
š info [vite] Pruned assets/index-C3xT9k.css against every environment once the last had written, which restored a rule the earlier prune removed: 3959 ā 4102 bytes, now assets/index-C3xT9k.b-cUVtMv.css.
A class only the server graph reaches keeps its rule. That is what a styled component rendering only on the server
needs, and what React Server Components need for most components, since most never reach the client graph. The sheet is
pruned in a pre-ordered generateBundle hook, so a plugin that records asset names in its own hook ā the RSC plugin's
manifest does ā records the final name.
An in-memory build (write: false) has no file to finalize, and a run that builds environments one at a time without
announcing them has no last environment to wait for. Both prune the sheet as it is emitted, and a class a later
environment compiles that the sheet no longer carries fails that build, naming the classes:
bamboocss: 2 class(es) compiled in the "ssr" environment were already pruned out of a
stylesheet emitted by an earlier one. Elements carrying them would render unstyled.
md\:d_inline-block
h_\[31\.3px\]
Configure builder so the run announces itself, or set pruneCss: false to ship the whole extracted stylesheet. A run
that declares an environment and never builds it is reported as the process exits.
Seeing every diagnostic
Compiler errors list the first few findings and count the rest, which is right for reading one failure and wrong for
scoping a migration. Set BAMBOO_DIAGNOSTIC_LIMIT to a number, or to all, to see the whole list:
BAMBOO_DIAGNOSTIC_LIMIT=all vite build
Compilation and strict rejection are not options. A style-producing call that cannot be represented by the emitted CSS fails instead of retaining a runtime fallback. See Source compilation for the accepted static and finite-dynamic shapes.
Troubleshooting
If your IDE does not autocomplete styled-system imports, add that directory to include in tsconfig.json. See
the CLI guide.