references
config

Configuring Bamboo

Customize how Bamboo works via the `bamboo.config.ts` file in your project.

Customize how Bamboo works via the bamboo.config.ts file in your project.

import { defineConfig } from '@bamboocss/dev'
 
export default defineConfig({
  // your configuration options here...
})

Output css options

presets

Type: (string | Preset | Promise<Preset>)[]

Default: ['@bamboocss/preset-base', '@bamboocss/preset-bamboo']

The set of reusable and shareable configuration presets.

By default, any preset you add will be smartly merged with the default configuration, with your own configuration acting as a set of overrides and extensions.

{
  "presets": ["@bamboocss/preset-base", "@bamboocss/preset-bamboo"]
}

presets is the complete list: what you write is what is loaded. Leaving it unset loads defaultPresets (the two above); setting it to [] loads nothing, which is what the removed eject: true meant. To add a preset without restating the defaults:

import { defaultPresets } from '@bamboocss/dev/presets'
 
export default defineConfig({
  presets: [...defaultPresets, myPreset],
})

preflight

Type: boolean | { scope?: string; level?: 'element' | 'parent'; prune?: boolean }

Default: false

Whether to enable css reset styles. See also Global styles for how reset interacts with global variables and layering, and preflight.prune for dropping the parts of the reset that style elements your source never renders.

Enable preflight:

{
  "preflight": true
}

You can also scope the preflight; Especially useful for being able to scope the CSS reset to only a part of the app for some reason.

Enable preflight and customize the scope:

{
  "preflight": { "scope": ".extension" }
}

The resulting reset css would look like this:

.extension button,
.extension select {
  text-transform: none;
}
 
.extension table {
  text-indent: 0;
  border-color: inherit;
  border-collapse: collapse;
}

Setting level: 'element' (defaults to parent) puts the scope on the element instead – button.extension rather than .extension button – so only elements carrying the scope class are reset.

preflight.prune

Whether to drop the parts of the reset that style elements your source never renders.

Two thirds of the reset is bound to specific elements – 41 of them, covering table, pre, kbd, optgroup and the rest of the long tail. The reset is a fixed size, so it dominates a small stylesheet rather than amortising like the utilities layer does.

{
  "preflight": { "prune": true }
}

Measured on the example apps in this repository:

apprawgzipbrotli
vite-ts-13.2%-14.8%-14.2%
svelte-33.9%-29.1%-29.3%

Unlike most CSS size work, this holds up under compression, because it emits less rather than spelling the same thing differently.

A selector list loses only the parts naming unrendered elements, so a rule shared between button and ::file-selector-button keeps the half that still applies. A rule goes only when every part does. html and body are never removed, and a selector naming no element at all – *, ::backdrop, [hidden], a class – is always kept, since nothing about it says which element it reaches.

⚠️

This one cannot be made safe by default, and it is the only pruning option here that cannot. The token and keyframe passes have something to prove reachability against; this has a textual scan of your own source and nothing else. An element rendered by a dependency's component, by dangerouslySetInnerHTML, or by markdown is invisible to it. What you get wrong is an element quietly losing its reset – no error, no warning, just a <table> that has its browser default border spacing back. Reach for it when you control the markup, and check the result.

Bamboo's own documentation site is the example: its include covers ./src and ./app, its prose lives in .mdx under content/, and the scan therefore sees 49 elements. Turning this on there removes the reset for twenty it never sees — including h5, which the docs render today, and input, which a search dialog renders from a dependency. It saves 450 bytes of a 2,973-byte reset, or 165 compressed.

Because being wrong is silent, the build names what it removed — all of it, once per project — whenever this is on:

🎋 info [prune:preflight] Reset rules removed for 20 element(s) your source never renders: abbr, audio, b, canvas,
dialog, embed, h5, h6, iframe, input, menu, object, optgroup, progress, samp, select, small, sub, sup, textarea.
Anything rendered by a dependency, by markdown, or through `dangerouslySetInnerHTML` is invisible to this scan —
check the list, or set `preflight: { prune: false }`.

The list says a rule for that element went, not that nothing styles it any more.

Check your entry template first. The scan reads the files include covers, and include conventionally covers components rather than markup: ./src/**/*.tsx does not match index.html, where <noscript>, a static <table> and the rest of a page's hand-written markup usually live. Name the template – or any other markup outside include, such as an email template or a server-rendered layout – alongside your components; the scan reads any file include covers, not only ones the parser understands:

include: ['./src/**/*.{ts,tsx}', './index.html']

Each file is read from disk rather than from the parsed copy the build holds, so a .svelte or .vue file keeps the markup its transform to TSX would have dropped.

A scoped reset is handled – preflight: { scope: '.app' } writes .app table, and the scope is stripped before the element is read out. bamboo cssgen preflight prunes as well, so the reset.css it writes matches the one a full build produces.

emitTokensOnly

Type: boolean

Default: false

Whether to only emit the tokens directory

prefix

Type: string | { cssVar?: string; className?: string }

Default: ''

The namespace prefix for the generated css classes and css variables. A string prefixes both; the object form sets them separately.

The examples in this section all use the same call, referred to below as the shared example:

import { css } from '../styled-system/css'
 
const App = () => {
  return <div className={css({ color: 'blue.500' })} />
}

Ex: when using a prefix of bamboo

{
  "prefix": "bamboo"
}

would result in:

.bamboo-text_blue\.500 {
  color: var(--bamboo-colors-blue-500);
}

layers

Type: Partial<CascadeLayers>

Default: { reset: 'reset', base: 'base', tokens: 'tokens', recipes: 'recipes', utilities: 'utilities' }

Cascade layers used in generated css.

Ex: when customizing the utilities layer

{
  "layers": {
    "utilities": "bamboo_utilities"
  }
}

the shared example would result in:

@layer bamboo_utilities {
  .text_blue\.500 {
    color: var(--colors-blue-500);
  }
}

You should update the layer in your root css also.

separator

Type: '_' | '=' | '-'

Default: '_'

The separator for the generated css classes.

Using a =, the shared example would result in:

.text\=blue\.500 {
  color: var(--colors-blue-500);
}

minify

Type: boolean

Default: false

Whether to minify the generated css.

{
  "minify": true
}

Measured on the example apps in this repository:

apprawgzipbrotli
vite-ts-21.6%-5.6%-4.8%
svelte-20.4%-6.6%-7.3%

If you import the stylesheet through Vite you probably do not need this. Vite and any PostCSS setup with cssnano already minify CSS in their production build. It matters when you ship styled-system/styles.css as-is: linked straight from an HTML file, served from a CDN, or published inside a component library.

Pass --minify to bamboo cssgen to enable it for a single build without changing the config. bamboo debug always writes unminified css, whatever this is set to.

hash

Type: boolean | 'auto' | { cssVar?: boolean | 'auto'; className?: boolean | 'auto' }

Default: false

Whether to hash the generated class names / css variables. This is useful if want to shorten the class names or css variables. true hashes both; the object form controls each independently, with omitted keys defaulting to false. 'auto' keeps names readable in the Vite dev server and hashes them in production builds and CLI output. The Vite integration sets the development mode for you.

export default defineConfig({
  hash: 'auto',
  // Or hash only class names in production:
  // hash: { className: 'auto', cssVar: false },
})

For the shared example, the emitted class and variable are:

settingclassvariable
false.text_blue\.500var(--colors-blue-500)
true.dOFUTEvar(--cgpxvS)
{ cssVar: true, className: false }.text_blue\.500var(--cgpxvS)
{ cssVar: false, className: true }.dOFUTEvar(--colors-blue-500)

This option applies to every build path, Vite included — it is the only thing that shortens class names. Vite's compiler composes StyleSets before naming and then emits the names the stylesheet is written under, so hashing changes what those names look like without changing cx semantics.

watermark

Type: boolean

Default: true

Whether to include the 🎋 emoji in the stylesheet's --made-with-bamboo declaration on :root.

export default defineConfig({
  watermark: false,
})

false changes the value to an empty string. The declaration stays in the CSS because the Vite plugin uses it to recognize Bamboo's stylesheet after a bundler merges or minifies it.

File system options

gitignore

Type: boolean

Default: true

Whether to update the .gitignore file.

Will add your outdir to your .gitignore file:

## Bamboo
styled-system

cwd

Type: string

Default: process.cwd()

The current working directory.

{
  "cwd": "src"
}

clean

Type: boolean

Default: false

Whether to clean the output directory before generating the css.

outdir

Type: string

Default: styled-system

The output directory for the generated css.

importMap

Type: string | ImportMapInput | Array<string | ImportMapInput>

Default: { "css": "styled-system/css", "recipes": "styled-system/recipes", "patterns": "styled-system/patterns", "tokens": "styled-system/tokens" }

Allows you to customize the import paths for the generated outdir.

{
  "importMap": {
    "css": "@acme/styled-system",
    "recipes": "@acme/styled-system",
    "patterns": "@acme/styled-system"
  }
}

A string is the base import path with the default entrypoints appended – "@scope/styled-system" resolves to @scope/styled-system/css, /recipes, /patterns and /tokens:

{
  "importMap": "@scope/styled-system"
}

An array of either form is also accepted, and matches an entrypoint imported from any of them.

Check out the Component Library guide for more information on how to use the importMap option.

include

Type: string[]

Default: []

List of files glob to watch for changes.

{
  "include": ["./src/**/*.{js,jsx,ts,tsx}", "./pages/**/*.{js,jsx,ts,tsx}"]
}

exclude

Type: string[]

Default: []

List of files glob to ignore.

dependencies

Type: string[]

Default: []

Explicit list of config related files that should trigger a context reload on change.

💡

We automatically track the config file and (transitive) files imported by the config file as much as possible, but sometimes we might miss some. You can use this option as a workaround for those edge cases.

{
  "dependencies": ["path/to/files/**.ts"]
}

watch

Type: boolean

Default: false

Whether to watch for changes and regenerate the css.

This is the CLI's own watcher — bamboo --watch. A bundler integration does not read it and does not need it: @bamboocss/vite re-extracts as you edit source, and declares your config to Vite as a config file, so editing it — or a preset it imports — restarts the dev server.

poll

Type: boolean

Default: false

Whether to use polling instead of filesystem events when watching.

outExtension

Type: 'mjs' | 'js'

Default: mjs

File extension for generated javascript files.

forceConsistentTypeExtension

Type: boolean

Default: false

Whether to force consistent type extensions for generated typescript .d.ts files.

If set to true and outExtension is set to mjs, the generated typescript .d.ts files will have the extension .d.mts.

{
  "forceConsistentTypeExtension": true
}

LightningCSS

Use LightningCSS by installing @bamboocss/plugin-lightningcss and listing it:

import { pluginLightningcss } from '@bamboocss/plugin-lightningcss'
 
export default defineConfig({
  plugins: [pluginLightningcss()],
})

There was a lightningcss: boolean option whose only job was to push that plugin into this list. Naming the plugin from inside @bamboocss/node made it a static import, so every project installed a native binary whether or not the flag was ever set — the package is separate precisely so that cost is opt-in.

browserslist

Type: string[]

Default: unset – @bamboocss/plugin-lightningcss fills it in from the browserslist config found in your project when you do not set it.

Browserslist query to target specific browsers. Only used by the LightningCSS optimizer.

{
  "browserslist": ["last 2 versions", "not dead", "not < 2%"]
}

Design token options

shorthands

Type: boolean

Default: true

Whether to allow shorthand properties.

settingaccepted
truecss({ bgColor: 'gainsboro', p: '10px 15px' })
falselonghand only – backgroundColor, padding

prune

Type: { tokens?: boolean; keepTokens?: string[]; unresolvedPath?: 'off' | 'warn' | 'error'; propertyRegistrations?: boolean; keyframes?: boolean }

Default: { tokens: true, unresolvedPath: 'off', propertyRegistrations: true, keyframes: true }

What to drop from the generated stylesheet. Each key is an independent switch, and setting one keeps the defaults for the rest. The reset is not here — it is pruned by preflight.prune, alongside the option that emits it and the scope that pruning has to strip.

{
  "prune": { "tokens": true, "keyframes": true }
}

prune.tokens

Whether to drop token css variables nothing asks for. On by default.

The token layer declares every token in your theme, and an app typically uses a small fraction of them, so this is usually the largest single saving in render-blocking css. It scales with the size of your design system rather than the size of your app. Across the example apps in this repository it is worth 10–67% of the gzipped stylesheet.

{
  "prune": { "tokens": false }
}

A variable is kept when the generated css references it, when a kept variable's own value references it, or when javascript under include names it — a token() or token.value() call, or a literal var(--x) written by hand. Paths are read through a constant or a template literal the extractor can follow, not only from a literal spelled at the call. Anything a theme refers to is kept as well, since themes ship as separate artifacts injected at runtime.

A path the build cannot follow keeps every declaration rather than dropping one something still asks for: token() hands back a var() for every token, so an unreadable path could name any of them. That fallback is what unresolvedPath reports and keepTokens replaces with a bound you declare.

Turning it off does not bring back the @property rules your utilities register: those are not tokens, so reachability never applied to them, and a preset ships the set it composes from regardless of what you draw.

💡

This was three strategies — 'off' | 'reachable' | 'accounted' — which conflated how hard to try with what to say when it fails. 'reachable' answered one cheap question, does any javascript reach for a token, and threw away everything else it had read, so a single token() call anywhere kept all 468 declarations of the default preset. 'accounted' did the work properly but was framed as an assertion, so it reported by default and had to be asked for. Doing the work is now the default, saying so is unresolvedPath, and a file that never spells token is skipped entirely — so the accounting costs nothing where there is nothing to account for.

prune.unresolvedPath

What to do about a token path the build cannot follow. 'off' (the default) falls back and says nothing; 'warn' falls back and reports; 'error' fails the build. The keeps are identical across all three — this decides how loudly, and nothing else. It is inert under tokens: false, which keeps everything and runs no accounting pass.

It defaults to 'off' because pruning is an inference the build makes on its own rather than a claim you asked it to check. Reach for 'warn' when the token layer is larger than you expect — it names what is holding the keep set open:

⚠ tokens:unresolved  2 token reference(s) could not be resolved, so every token declaration is kept.

  src/chart.tsx
    14: unresolved-reference
  src/theme.ts
    3: unclassified-import

Set prune: { unresolvedPath: 'error' } to assert that every token path in your project is spelled out at the call, so the fallback can never ship unnoticed. Reach for 'warn' first: it runs the same accounting and prints the same references, so you can see what 'error' would reject before a build depends on it.

A reference it cannot read is never pruned — the declaration it might have named is kept. So the assertion is never less safe than leaving it off, and the message tells you why when it cannot prune.

A dynamic path is bounded, not declined

token(`colors.${shade}`) cannot say which token it wants, but it says which it cannot. The static head is a bound on everything the expression can produce, so the colors category is kept and nothing else — no decline, no fallback. That covers the commonest dynamic read outright, and it is worth knowing before you conclude that your project cannot be pruned:

// bounded — keeps `colors.*`, prunes the rest
token(`colors.${shade}` as Token)
 
// unfollowable — nothing bounds it
token(key)
token('colors.' + shade)
A local token is not a token reference

token is the obvious name for a token object, and binding one shadows nothing:

items.map((token) => token.value) // a parameter, not the artifact

Bamboo resolves the binding rather than the spelling, so a parameter (destructuring included), a catch variable, a function or class declaration, a named function or class expression, and a variable destructured off one of those are all skipped. A type or class member named token names a property and reads nothing, so it is skipped too.

What still declines is anything whose value the build cannot see:

const { token } = useTheme() // an initializer that could be the artifact, through a barrel
const { token } = ui // a namespace import, likewise
theme.token(key) // a member on an object bamboo never bound

prune.keepTokens

Token paths to keep whatever the build can see, as exact names or * patterns.

{
  "prune": { "keepTokens": ["colors.*"] }
}

This is the bound the build could not infer, written by hand. It exists because the fallback is otherwise total: one reference the accounting cannot follow keeps every declaration in the project, so a codebase with a single token(key) in it ships the same stylesheet as one that never prunes.

It does two things — it keeps what it matches, and it stands in for what could not be followed, in place of the blanket keep. Measured on sandbox/vite-ts with one unfollowable token(key) in it:

settingdeclarationsstylesheet
no keepTokens42623,412 B
keepTokens: ['colors.*']27017,867 B
keepTokens: ['colors.red.*']5110,649 B

Patterns match the dotted token path, anchored and case-sensitively, with * standing for any run of characters and a leading ! excluding. colors.* keeps every colour, colors.brand.* one palette, colors.red.300 one token, and ['colors.*', '!colors.legacy.*'] every colour but one palette.

⚠️

Patterns match the token path, not the css variable. A token is fontSizes.3xl and its declaration is --font-sizes-3xl, so font-sizes.* — the natural thing to write after reading styles.css — matches nothing at all. The same goes for lineHeights, letterSpacings, aspectRatios and every other multi-word category. A category name on its own (colors) is not a path either.

A pattern matching no token is reported rather than ignored, and names the spelling that would have worked, because it is nearly always a typo and keeping nothing is otherwise silent. A list holding only exclusions is reported too: ! subtracts from a selection, so ['!colors.legacy.*'] on its own selects everything else rather than nothing.

⚠️

Saying keepTokens: ['colors.*'] is saying the reads you cannot follow land in colours — an assertion about your own code, which is why nothing infers it for you. Nothing verifies it: a read landing outside the patterns loses its declaration and resolves to a var() with nothing behind it, which inherits rather than falling back. The references being covered are still printed under 'warn', so check them.

'error' and keepTokens do not combine, and the build says so: one asserts every path resolves, the other declares where the ones that do not will land. A project that cannot make the first assertion wants 'warn'.

With nothing to stand in for, this is additive only — naming a token nothing in the stylesheet references and no javascript here reads, such as one consumed by a sibling package or by css outside include. It is inert under tokens: false, which keeps everything already.

This replaces staticCss as the way to keep a token category alive. staticCss emits utility classes, so keeping the colours meant shipping a rule per colour purely to hold the declarations up — usually a larger stylesheet than the pruning saved.

These resolve, and cost nothing:

token('colors.red.300') // a string literal
token.value('spacing.4') // either half
t('colors.red.300') // `import { token as t }`
ds.token('colors.red.300') // `import * as ds`

Two things keep strict inert rather than wrong, and both are reported. A file whose parsed tree carries syntax errors declines — a .js file using TypeScript-only syntax, or one a parser:before hook rewrote. And a .vue or .svelte file mentioning token anywhere declines, because a single-file component is stored post-transform and the tree is not the code that ships.

A path built from a template literal is bounded rather than reported: Bamboo cannot tell which token token(`colors.${shade}`) wants, but it knows the answer starts with colors., so it keeps that category and prunes the rest. An empty head — token(`${path}`) — bounds nothing and is reported.

These are reported and keep the whole layer:

token(`${name}`) // built at runtime, with nothing to bound it
token(KEY) // a constant — resolvable for extraction, not here
const t = token // the binding escapes
export { token } from 'styled-system/tokens' // re-exported
const { token } = require('styled-system/tokens') // not statically bound
import { token } from '@acme/ui' // a barrel Bamboo cannot classify
⚠️

strict is an assertion, and one thing it cannot check for you: a caller outside include. That scopes style extraction, not everything that may import — a build script, a config file, or a sibling workspace package calling token() is invisible, and pruning against it produces a var() with no declaration, which inherits rather than falling back. Check that include covers every file that reaches for a token before turning this on. bamboo init scaffolds ./src/** and ./pages/**; a React Router ./app/** directory, including ./app/root.tsx, or plain .ts files under a .tsx-only glob are the common gaps.

prune.keyframes

Whether to drop @keyframes rules nothing in the generated css can reach.

A preset declares every animation it offers and an app uses a handful; the rest sit in the same render-blocking stylesheet. Like prune.tokens, the saving scales with the size of your design system rather than the size of your app. The two are independent – either can be switched off without the other.

{
  "prune": { "keyframes": false }
}

Only keyframes your theme declares are ever removed, so one emitted by global.css is left alone. A name is kept when an animation property in the generated css names it, when a theme names it, when it is named by a custom property that is itself reachable, or when it appears anywhere under include.

That last clause is a textual scan, and it is deliberately over-inclusive: it covers an animation name assembled at runtime or handed to an inline style rather than to Bamboo, neither of which the css can show. A name matching a word in unrelated prose keeps its keyframe alive, which is the cheap failure.

"A custom property that is itself reachable" means reachable to prune.tokens, not merely referenced somewhere in the stylesheet. A token can be reached from outside the css entirely — a token() call, a keepTokens pattern, a theme injected at runtime, a global.css export — and those are exactly the declarations prune.tokens keeps. So a keyframe is dropped only when the declarations naming it were dropped too, and under prune: { tokens: false }, where nothing is removable, every keyframe a declaration names is kept.

This is why you do not have to keep your own outdir inside include to protect your animations. The textual scan reads whatever include covers, and the generated token artifact spells each animation value out in full — so a project whose include reached its own output was keeping its keyframes by accident, and excluding outdir took the accident away.

Names are recovered by testing each token in a value against the set your theme declares, rather than by parsing the animation shorthand – whose parts come in any order. A keyframe named after a keyword such as none or ease therefore always looks referenced. The bias is intentional throughout: keeping an unused keyframe costs bytes, dropping a used one silently stops an animation.

cssVarRoot

Type: string

Default: :where(:root, :host)

The root selector for the css variables.

conditions

Type: Extendable<Conditions>

Default: {}

The css selectors or media queries shortcuts.

{
  "conditions": { "hover": "&:hover" }
}

global.css

Type: Extendable<GlobalStyleObject>

Default: {}

The global styles for your project.

{
  "global": {
    "css": {
      "html, body": {
        "margin": 0,
        "padding": 0
      }
    }
  }
}

At-rules work here too, so @font-face written directly in global.css does emit. Prefer global.fontface for fonts anyway: it emits the same rule and registers the family name as a value fontFamily accepts. Declared through global.css, the name is a string Bamboo has never heard of — no autocomplete, and under strictValues a build error.

// fontFamily accepts Tokens["fonts"] | "Roboto"
global: {
  fontface:   { Roboto: { src: "url(/fonts/roboto.woff2) format('woff2')" } },
}
 
// fontFamily accepts Tokens["fonts"] only — the @font-face still ships
global: {
  css:   { '@font-face': { fontFamily: 'Roboto', src: "url(/fonts/roboto.woff2) format('woff2')" } },
}

Same for global.positionTry, which registers its names as values positionTryFallbacks and positionTry accept, and adds the leading -- those require. Written here you have to remember the --, and @position-try flip is invalid css that ships silently and never fires.

global.vars

Type: Extendable<GlobalVarsDefinition>

Default: {}

Additional global css variables, emitted at cssVarRoot. A string value declares the variable directly; an object value registers it with @property. See Global styles for a worked example.

theme

Type: Extendable<Theme>

Default: {}

The theme configuration for your project.

{
  "theme": {
    "tokens": {
      "colors": {
        "red": { "value": "#EE0F0F" },
        "green": { "value": "#0FEE0F" }
      }
    },
    "semanticTokens": {
      "colors": {
        "danger": { "value": "token(colors.red)" },
        "success": { "value": "token(colors.green)" }
      }
    }
  }
}

theme.variants

Type: Extendable<ThemeVariantsMap>

Default: {}

The theme variants configuration for your project.

{
  "theme": {
    "variants": {
      "primary": {
        "tokens": {
          "colors": {
            "text": { "value": "red" }
          }
        },
        "semanticTokens": {
          "colors": {
            "muted": { "value": "token(colors.red.200)" },
            "body": {
              "value": {
                "base": "token(colors.red.600)",
                "_osDark": "token(colors.red.400)"
              }
            }
          }
        }
      }
    }
  }
}

utilities

Type: Extendable<UtilityConfig>

Default: {}

The css utility definitions.

{
  "utilities": {
    extend: {
      borderX: {
        values: ['1px', '2px', '4px'],
        shorthand: 'bx', // `bx` or `borderX` can be used
        transform(value, { token }) {
          return {
            borderInlineWidth: value,
            borderColor: token('colors.red.200'), // read the css variable for red.200
          }
        },
      },
    },
  }
}

patterns

Type: Extendable<Record<string, AnyPatternConfig>>

Default: {}

Common styling or layout patterns for your project.

{
  "patterns": {
    extend: {
      // Extend the default `flex` pattern
      flex: {
        properties: {
          // only allow row and column
          direction: { type: 'enum', value: ['row', 'column'] },
        },
      },
    },
  },
}

staticCss

Type: StaticCssOptions

Default: {}

Used to generate css utility classes for your project.

{
  "staticCss": {
    css: [
      {
        properties: {
          margin: ['*'],
          padding: ['*', '50px', '80px'],
        },
        responsive: true,
      },
      {
        properties: {
          color: ['*'],
          backgroundColor: ['green.200', 'red.400'],
        },
        conditions: ['light', 'dark'],
      },
    ],
  },
}

This is for classes your source never writes — a class name assembled at runtime, or one applied from outside the files include covers.

It is not the way to stop a token being pruned, which is what it used to be reached for. staticCss emits a utility rule per value, so keeping a colour palette alive meant shipping a rule per colour purely to hold its declaration up — usually a larger stylesheet than the pruning saved. Name the tokens with prune.keepTokens instead, which keeps the declarations without emitting anything that uses them.

strictValues

Type: boolean

Default: false

Require every style value to be a token, so a raw CSS value has to be written [14px].

css({ color: 'red.300' }) // ✅ a token
css({ display: 'flex' }) // ✅ a keyword — `flex` is the only way to say it
css({ animationName: 'fadeIn' }) // ✅ an identifier you invented
css({ transitionProperty: 'color' }) // ✅ a css property name, where the grammar asks for one
 
css({ fontSize: '14px' }) // ❌ write `[14px]`
css({ color: '#fff' }) // ❌
css({ border: '1px solid red' }) // ❌

This is a design-system policy — "everything goes through the theme" — and the brackets are what make reaching outside it visible in the source, where fontSize: '14px' reads exactly like using the scale. On one otherwise-correct five-page app it reports 468 values, which is what makes it a day-one decision.

It is reported by the build and graded by validation, against the styles your source produced — a preset's reset and your own config recipes are not held to a policy about your source. It was a set of TypeScript narrowings until 1.x; the grammar is what lets it tell a keyword from a raw value, which the types could not, and it is why transitionProperty: 'color' is no longer rejected in favour of 'colors'.

It is not what catches a misspelled token — see unresolvedToken, which is on by default.

strictPropertyValues

Type: boolean

Default: false

Only use valid CSS values for properties that do have a predefined list of values. Will throw for properties that do not have config tokens, such as display, content, willChange, etc. Learn more.

global.fontface

Type: GlobalFontfaceDefinition

Default: {}

Global font face definitions.

{
  "global": {
    "fontface": {
      "Inter": {
        "src": "url(/fonts/inter.woff2) format('woff2')",
        "fontWeight": "400",
        "fontStyle": "normal"
      },
      "Roboto": {
        "src": "url(/fonts/roboto.woff2) format('woff2')",
        "fontWeight": "400",
        "fontStyle": "normal"
      }
    }
  }
}

Each key becomes a value fontFamily accepts, alongside your fonts tokens — so css({ fontFamily: 'Inter' }) autocompletes and typechecks. That is the reason to declare a face here rather than as a raw @font-face in global.css, which emits the same rule but leaves the family name unregistered.

Check out the Custom Fonts guide for more information on how to use the global.fontface option.

global.positionTry

Type: Extendable<GlobalPositionTry>

Default: {}

Named @position-try (opens in a new tab) fallbacks for anchor positioning. Each key emits one @position-try rule, and a key without a leading -- gets one added, so bottom-scrollable below becomes --bottom-scrollable.

Each name is also registered as a value positionTryFallbacks and positionTry accept, so css({ positionTryFallbacks: '--bottom-scrollable' }) autocompletes and — under strictValues — typechecks. That is the reason to declare one here rather than as a raw @position-try in global.css, which emits the same rule but leaves the name unknown.

{
  "global": {
    "positionTry": {
      "bottom-scrollable": {
        "positionArea": "block-start span-inline-end",
        "alignSelf": "stretch"
      }
    }
  }
}

Diagnostics options

logLevel

Type: 'debug' | 'info' | 'warn' | 'error' | 'silent'

Default: info

The log level for the built-in logger.

validation

Type: 'off' | 'warn' | 'error'

Default: warn

What to do when the config does not validate.

  • When set to 'off', no validation will be performed.
  • When set to 'warn', warnings will be logged when validation fails.
  • When set to 'error', errors will be thrown when validation fails.
{
  "validation": "error"
}

This grades opinions about a config that still builds. Two checks are not that, and are not governed by this option — they throw at every setting, including off:

  • A token value written in a retired reference syntax, which is output that is already broken.
  • An option that has been removed or renamed, which is proof the config predates the version reading it. The error names each key and what to write instead.

A removed option throws rather than warns because nothing else notices it. There is no schema walk, so a key that no longer exists is otherwise ignored in silence: the build reverts to the default, and any assertion the option asked for stops being enforced. Removals ship in minor versions, so a warning is what an automated dependency upgrade merges without a person reading it. An unknown key is a different case and is still tolerated — it may be a setting for a version you have not installed yet. A removed key can only point backwards.

unresolvedToken

Type: 'off' | 'warn' | 'error' | { token?: …; grammar?: … }

Default: { token: 'error', grammar: 'warn' }

What to do about a style value that names neither a token nor anything the CSS property accepts.

  • off says nothing.
  • warn logs each one as it is transformed.
  • error fails the build, listing every one it found.

The two halves are graded apart, because they are not equally certain.

halfwhendecided bydefault
tokenthe property draws from a token category, or the value is a dotted pathbamboo's own bookkeepingerror
grammara bare name on a property with no token categorythe CSS grammarwarn

color: 'mutedd' and top: 'navH' are the first: color reads colors and top reads spacing, so a name that is not one of those is bamboo's own answer and there is nothing else to be wrong about it.

display: 'flexx' is the second, and so is containerType: 'scroll-state' — which is valid CSS the grammar's data has not caught up with. Sweeping every keyword csstype enumerates through the grammar found 8 such disagreements in 10,128 pairs, 0.08%, and every one of them was on a property with no token category. Keeping that half at warn is what stops a build failing over how fresh a grammar is.

Pass a single severity to apply it to both:

{
  "unresolvedToken": "error"
}

A missing token such as css({ background: 'accent.default' }) fails the build by default. If you lower the token severity to warn or off, resolution falls back to the original value: background: accent.default can reach the stylesheet, where the browser drops it. Use [accent.default] only when you intend a literal value; that bypasses this token check, but invalidDeclaration can still report invalid CSS.

Every style the build transforms is graded, wherever it was written — a css() call, staticCss, and the styles your config supplies: global.css, the preflight scope, config recipes and mixins. The three settings differ in what they do about a finding, never in which styles they look at.

The modifiers a path can carry are stripped before it is resolved, so accent.default!, accent.default !important and accent.default/50 are all judged as accent.default and reported once between them. The opacity modifier is only stripped for a property drawing on colors, since that is the only place it means anything — a slash stays part of the value in font: 12px/1.5 serif.

Not to be confused with prune.unresolvedPath, which is about a token() call whose path the prune scan cannot follow statically — a question about pruning coverage, asked of a token that usually exists. This one is about a token that does not.

Under error the check is asked of the stylesheet being emitted rather than of the calls that built it, so it reports what the file about to be written contains. The Vite builder re-extracts affected sources when they change, including removing contributions from deleted files.

A binding that does not exist is not graded here and always fails the build; see below.

invalidDeclaration

Type: 'off' | 'warn' | 'error'

Default: warn

What to do about a declaration in the emitted stylesheet that is not valid CSS for its property.

  • off says nothing.
  • warn logs each one, once, the first time a sheet is emitted with it.
  • error fails the build, listing every one the sheet holds.

This is asked of the finished sheet rather than of the styles that built it, so it sees what a utility transform, a mixin, a config recipe, the […] escape hatch and the preset's own reset actually emitted. bgLinear: '65deg' reaches the sheet as background-image: 65deg, and width: '[10px 20px]' as width: 10px 20px. Both parse, so the stylesheet is valid and nothing downstream objects; the browser drops the declaration at compute time and the style is simply absent.

2 declaration(s) in the stylesheet are not valid CSS for their property:

- `background-image: 65deg` in `.bg-linear_65deg`, `@layer utilities`
- `width: 10px 20px` in `.w_\[10px_20px\]`, `@layer utilities`

A value that reads a variable — var(), env(), attr(), if() — is not checked, because the grammar cannot see what will be substituted. In a token-backed sheet that is most declarations: across the generated stylesheets in this repository it was 273 of 1,527. A property the grammar has never heard of is not reported either; only a value the grammar knows and rejects is.

The default is warn because the judge is the CSS grammar, whose data lags the spec. display: 'masonry', containerType: 'scroll-state', calc-size() and contrast-color() are valid CSS it has not caught up with and would be reported. Escalate once the sheet is known to be clean.

A value naming a token that does not exist belongs to unresolvedToken, whatever that is set to, and is never reported here — so turning that check off is not answered by this one.

Calls to a binding that does not exist

Not an option — there is no setting under which it is what someone meant.

styled-system/patterns and styled-system/recipes are generated from your config, so what they export moves when it does: a pattern dropped from a preset, a recipe renamed. The import survives that as a binding to nothing. Nothing extracts the call, so every rule it would have contributed is absent and the class the component asks for has nothing behind it — while the build prints its ticks and exits 0.

error: 1 call(s) name a binding that does not exist:

src/modal.tsx
  `stack` is not a pattern — `../styled-system/patterns` does not export it.

Reported per call, not per import: both entrypoints export types beside their functions — FlexProperties, ButtonVariantProps — and importing one of those is ordinary. A binding nobody calls is left alone too, since nothing asked it for a class.

This is the same test prune.unresolvedPath and extraction failures already apply, against the third way of arriving at the same output: a stylesheet missing rules that a green build did not mention.

Other options

Plugins

Type: BambooPlugin[]

Plugins are simple objects that contain a name and a hooks object. They are the only way to register a hook — Bamboo provides a set of callbacks you can hook into for more advanced use cases, and the Hooks docs list them all.

They are called in sequence in the order they are defined in the plugins array.

A bare hooks key on the config used to be a second way to do this, treated as a nameless plugin appended last. It has been removed, and a config still setting it fails naming the replacement.

import { defineConfig } from '@bamboocss/dev'
 
export default defineConfig({
  // ...
  plugins: [
    {
      name: 'token-format',
      hooks: {
        'tokens:created': ({ configure }) => {
          configure({
            formatTokenName: (path) => '$' + path.join('-'),
          })
        },
      },
    },
  ],
})