Writing Styles
Bamboo generates the utilities you need to style your components with confidence.
Styles in Bamboo are written as objects.
Atomic Styles
When you write styles in Bamboo, it generates an atomic stylesheet scoped to the @layer utilities cascade layer.
Bamboo exposes a css function that can be used to author styles. It accepts a style object and returns a className
string.
import { css } from '../styled-system/css'
const styles = css({
backgroundColor: 'gainsboro',
borderRadius: '9999px',
fontSize: '13px',
padding: '10px 15px'
})
// Generated className:
// --> p_10px_15px bdr_9999px bg-c_gainsboro fs_13px
<div className={styles}>
<p>Hello World</p>
</div>
The styles generated at build time end up like this:
@layer utilities {
.p_10px_15px {
padding: 10px 15px;
}
.bdr_9999px {
border-radius: 9999px;
}
.bg-c_gainsboro {
background-color: gainsboro;
}
.fs_13px {
font-size: 13px;
}
}
Shorthand Properties
Bamboo provides shorthands for common css properties to help improve the speed of development and reduce the visual density of your style declarations.
Properties like borderRadius, backgroundColor, and padding can be swapped to their shorthand equivalent rounded,
bg, and p.
import { css } from '../styled-system/css'
// BEFORE - Good
const styles = css({
backgroundColor: 'gainsboro',
borderRadius: '9999px',
fontSize: '13px',
padding: '10px 15px',
})
// AFTER - Better
const styles = css({
bg: 'gainsboro',
rounded: '9999px',
fontSize: '13px',
p: '10px 15px',
})
Shorthands are documented alongside their respective properties in the utilities section.
Type safety
Bamboo is built with TypeScript and provides type safety for all style properties and shorthands. Most of the style
properties are connected to either the native CSS properties or their respective token value defined as defined in the
theme object.
import { css } from '../styled-system/css'
// โคต you'll get autocomplete for colors
const styles = css({ bg: '|' })
You can also enable the strictValues setting in the Bamboo configuration. true allows only token values and
prevents the use of custom or raw CSS values. A misspelled token needs no setting โ the build reports it either way;
see below.
config.strictValuesasks for[14px]around a raw css value. A keyword is not a raw value, sodisplay: 'flex'is unaffected.config.strictPropertyValueswill throw for properties that do not have config tokens, such asdisplay,content,willChange, etc. when the value is not a predefined CSS value.
In both cases, you can use the [xxx] escape-hatch syntax to use custom or raw CSS values without TypeScript errors.
strictValues
With config.strictValues enabled, you can only use token values in your styles. This prevents the use of custom or raw
CSS values.
bamboo.config.ts
import { css } from '../styled-system/css'
css({ bg: 'red' }) // โ Error: "red" is not a valid token value
css({ fontSize: '123px' }) // โ Error: "123px" is not a valid token value
css({ bg: 'red.400' }) // โ
Valid
css({ fontSize: '[123px]' }) // โ
Valid, since `[123px]` is using the escape-hatch syntax
css({ content: 'abc' }) // โ
Valid, since `content` isn't bound to a config tokenFor one-off styles, you can always use the escape-hatch syntax [xxx] to use custom or raw CSS values without
TypeScript errors.
bamboo.config.ts
import { css } from '../styled-system/css'
css({ bg: '[red]' }) // โ
Valid, since `[red]` is using the escape-hatch syntax
css({ fontSize: '[123px]' }) // โ
Valid, since `[123px]` is using the escape-hatch syntaxA token can carry an important mark or a colour opacity modifier, and the path in front of one is checked as usual:
css({ bg: 'red.400!' }) // โ
Valid
css({ bg: 'red.400/50' }) // โ
Valid
css({ bg: 'rd.400!' }) // โ Error: `rd.400` is not a token, mark or no mark
What follows the mark is not checked โ bg: 'red.400!nonsense' typechecks. Spelling that out as a closed set of
templates multiplies every token in the union by five, and it was around half the cost of typechecking a css() call
under strictValues. unresolvedToken resolves the path underneath the
mark, so the build still reports the value โ failing the build by default, exactly like rd.400 above.
A misspelled token is the build's job, not TypeScript's
color: 'mutedd' names no token. It used to be accepted by TypeScript and by the build, ship as color: mutedd, and be
dropped by the browser at compute time โ surfacing as a colour that never applied, a long way from the typo.
The build reports it now, with no setting to turn on and no migration to do:
`color: mutedd` โ no such `colors` token. It is emitted as written, and the browser will drop it.
Write `[mutedd]` to mean it literally.
It reads the CSS grammar rather than a TypeScript union, which is what lets it be precise about the interesting case โ a name that exists, on the wrong shelf:
`top: navH` โ `navH` is declared under `sizes`, but `top` reads `spacing`.
It is emitted as written, and the browser will drop it.
Use a `spacing` token, or write `[navH]` to mean it literally.
No type error can say that. TypeScript can only report that a string is not assignable to a union of two hundred
members, and guess a near-miss by spelling โ which is how transitionProperty: 'color', ordinary CSS, came to be
rejected in favour of 'colors', a utility value that emits seven declarations instead of one.
It also sees what the types cannot: values in globalCss, in config recipes, in a .vue or .svelte template, and in
any project not using TypeScript at all. Two of the four findings on this documentation site were in config recipes,
which tsc never checked.
Grade it with unresolvedToken: a nonexistent token name like navH above
fails the build by default, a keyword the CSS grammar itself doesn't recognize only warns, and 'off' silences either
half.
strictPropertyValues
With config.strictPropertyValues enabled, you can only use valid CSS values for properties that do have a predefined
list of values in your styles. This prevents the use of custom or raw CSS values.
bamboo.config.ts
css({ display: 'flex' }) // โ
Valid
css({ display: 'block' }) // โ
Valid
css({ display: 'abc' }) // โ will throw since 'abc' is not part of predefined values of 'display'
css({ pos: 'absolute123' }) // โ will throw since 'absolute123' is not part of predefined values of 'position'
css({ display: '[var(--btn-display)]' }) // โ
Valid, since `[var(--btn-display)]` is using the escape-hatch syntax
css({ content: '""' }) // โ
Valid, since `content` does not have a predefined list of values
css({ flex: '0 1' }) // โ
Valid, since `flex` does not have a predefined list of valuesconfig.strictPropertyValues only applies to properties that have a fixed set of values โ layout, box-model and
text-flow properties such as display, position, overflow, flexDirection, alignItems, visibility and the
*Style border properties. Border shorthands such as border and borderTop are not in the set. The generated
styled-system/types/style-props.d.ts is the authoritative list: a restricted property is typed there as a union of its
allowed values.
Nested Styles
Bamboo provides different ways of nesting style declarations. You can use the native css nesting syntax, or the built-in
pseudo props like _hover and _focus. Pseudo props are covered more in-depth in the next section.
Native CSS Nesting
Bamboo supports the native css nesting syntax. You can use the & selector to create nested styles.
Important: It is required to use the "&" character when nesting styles.
<div
className={css({
bg: 'red.400',
'&:hover': {
bg: 'orange.400',
},
})}
/>
You can also target children and siblings using the & syntax.
<div
className={css({
bg: 'red.400',
'& span': {
color: 'pink.400',
},
})}
/>
We recommend not using descendant selectors, and the reason is worth knowing before you reach for one: & span is
(0,1,1) where a class is (0,1,0), so it outranks any css() applied to that span directly. Cascade layers do not
separate the two โ both are in utilities, where specificity decides โ so the span carries the class it was given and
renders with the other value, with nothing to report. See
inside a layer, specificity still decides.
Colocating styles directly on the element is the preferred way of writing styles in Bamboo.
Using Pseudo Props
Bamboo provides a set of pseudo props that can be used to create nested styles. The pseudo props are prefixed with an
underscore _ to avoid conflicts with the native pseudo selectors.
For example, to create a hover style, you can use the _hover pseudo prop.
<div
className={css({
bg: 'red.400',
_hover: {
bg: 'orange.400',
},
})}
/>
See the pseudo props section for a list of all available pseudo props.
Global styles
Global styles are useful for applying additional global resets or font faces. Use the global.css property in the
bamboo.config.ts file to define global styles.
Global styles are inserted at the top of the stylesheet and are scoped to the @layer base cascade layer.
For resets, global variables, theming patterns, and more examples, see Global styles.
bamboo.config.ts
import { defineConfig, defineGlobalStyles } from '@bamboocss/dev'
const globalCss = defineGlobalStyles({
'html, body': {
color: 'gray.900',
lineHeight: '1.5',
},
})
export default defineConfig({
// ...
global: { css: globalCss },
})The styles generated at build time will look like this:
@layer base {
html,
body {
color: var(--colors-gray-900);
line-height: 1.5;
}
}
Style Composition
Merging styles
Passing multiple styles to the css function will deeply merge the styles, allowing you to override styles in a
predictable way.
import { css } from '../styled-system/css'
const result = css({ mx: '3', paddingTop: '4' }, { mx: '10', pt: '6' })
// ^? result = "mx_10 pt_6"
All objects passed to css() must be statically recoverable. For a component with runtime choices, declare the choices
as recipe variants:
src/components/Button.tsx
import type { ReactNode } from 'react'
import { cva } from '../../styled-system/css'
const button = cva({
base: { display: 'flex', alignItems: 'center' },
variants: {
tone: {
neutral: { color: 'black' },
brand: { color: 'pink.500', _hover: { color: 'red.500' } },
},
},
defaultVariants: { tone: 'neutral' },
})
export function Button({ tone, children }: { tone?: 'neutral' | 'brand'; children: ReactNode }) {
return (
<button type="button" className={button({ tone })}>
{children}
</button>
)
}The compiler can enumerate both tones and compile the runtime selection. An arbitrary css prop containing an open
style object has no such finite set; use dynamic styling for values that must remain
open.
For a statically known pattern or recipe selection, compose the resulting styles through cx():
import { css, cva, cx } from '../styled-system/css'
import { flex } from '../styled-system/patterns'
const patternClass = cx(flex({ align: 'center' }), css({ color: 'blue.400' }))
const button = cva({ base: { display: 'flex', color: 'red.500' } })
const recipeClass = cx(button(), css({ color: 'blue.400' }))
Both examples preserve the layout and select the blue color before allocating classes. The same static-composition
requirement applies when using .raw() style objects from inline recipes and individual slots.
Classname concatenation
Bamboo provides a cx function for combining class names. Vite gives it two deliberately different contracts:
- when every Bamboo argument is analyzable, the compiler merges their StyleSets before allocating classes;
- when an arbitrary runtime class is present, the generated helper is a tiny string join with no conflict guarantee.
import { css, cx } from '../styled-system/css'
const styles = css({
borderWidth: '1px',
borderRadius: '8px',
paddingX: '12px',
paddingY: '24px',
})
const Card = ({ className, ...props }) => {
const rootClassName = cx('group', styles, className)
return <div className={rootClassName} {...props} />
}
With fully analyzable Bamboo arguments, later declarations win and the losing atom is never allocated:
cx(css({ paddingX: '4' }), css({ paddingX: '2' }))
// one atom for padding-inline: 2
The compiler cannot infer the declarations behind className in the component example. When style objects are
statically available, compose them before allocating classes:
const base = { padding: '4' }
const compact = { padding: '2' }
const className = css(base, compact)
Use a declared recipe variant when a component needs a finite public choice. An arbitrary runtime style-object prop is
not statically available, so replacing className with css(base, props.css) does not make it compilable.
Recipes and css() share utility atoms. An opaque runtime class cannot be composed by the compiler, and its position
in a cx() call does not give it override priority. Use analyzable composition or a declared variant for an
intentional override.
In practice a foreign class wins that race: everything Bamboo emits lives in a cascade layer, and unlayered CSS beats
layered CSS whatever its specificity. The case to watch is an opaque class that is itself a Bamboo class โ one
assembled at runtime, or arriving through a prop the compiler cannot follow โ because then both sides are in
@layer utilities, and the sublayer order decides rather than the cx() argument order.
Which of the two you handed it is not something the compiler can see โ an opaque string is opaque โ so it reports every
such mix under opaque-composition, apart from an ordinary rejected call, so
reportSkipped and the build summary can name them.
Hashing
The hash option shortens class names and CSS variables, and it is the only thing that does. The Vite compiler and CLI
artifacts read the same setting, so a project sets it here rather than per command. Use hash: 'auto' for readable
names in the Vite dev server and hashed names in production and CLI output. See the
hash reference for independent class-name and CSS-variable settings.
bamboo.config.ts
import { defineConfig } from '@bamboocss/dev'
export default defineConfig({
// ...
hash: true,
})You might need to generate a new code artifact by running bamboo codegen --clean
When you write a style like this:
import { css } from '../styled-system/css'
const styles = css({
display: 'flex',
flexDirection: 'row',
_hover: {
bg: 'red.50',
},
})
The hash generated css will look like:
.fPSBzf {
display: flex;
}
.ksWBqx {
flex-direction: row;
}
.btpEVp:is(:hover, [data-hover]) {
background: var(--bINrJX);
}
We recommend that you use this in production builds only, as it can make debugging a bit harder.
Important styles
Applying important styles works just like CSS
css({
color: 'red !important',
})
You can also apply important using just the exclamation syntax !
css({
color: 'red!',
})
Fallback values
Some CSS values have no single spelling every browser understands. CSS handles this by letting you declare the same
property more than once โ a browser keeps the last declaration it can parse and discards the rest. A style object can't
express that, since it can't hold the same key twice, so Bamboo gives you fallback(...).
Write the candidates most-preferred first, either as a string or through the fallback helper:
import { css, fallback } from '../styled-system/css'
css({ height: fallback('calc(100dvh - 100px)', 'calc(100vh - 100px)') })
Both compile to the same value. The helper exists so the feature has an import to find and a signature to hover; the
string form is the one to reach for when a candidate is itself built by a call, since token() and friends cannot be
resolved from inside another function call:
css({ height: `fallback(${token('sizes.4')}, 100vh)` }) // โ
css({ height: fallback(token('sizes.4'), '100vh') }) // โ not extractable
import { css } from '../styled-system/css'
css({
height: 'fallback(calc(100dvh - 100px), calc(100vh - 100px))',
})
@layer utilities {
.h_fallback\(calc\(100dvh_-_100px\)\,_calc\(100vh_-_100px\)\) {
height: calc(100vh - 100px);
height: calc(100dvh - 100px);
}
}
The declarations come out in reverse, so a browser that doesn't understand dvh stops at the vh line and every other
browser reaches the one you actually wanted.
You can list as many candidates as you like, and each one resolves the same way a normal value would โ tokens, the
[...] escape hatch and shorthand properties all work inside a fallback:
css({
cursor: 'fallback(-webkit-grab, grab, move)',
color: 'fallback(red.300, red)',
paddingX: 'fallback(4, [2px])',
})
fallback(...) works anywhere a value does โ conditions, breakpoints, global.css, recipes and patterns. Adding
!important marks every candidate, not just the winning one.
What it can't do
The cascade only arbitrates between declarations of the same property: the browser keeps the last height it can
parse. So every candidate has to resolve to exactly one declaration. Most utilities do.
Some don't. transitionProperty emits a --transition-prop variable beside the property, lineClamp emits four
declarations for a number and one for none, and divideX emits a nested rule. The extra declarations aren't part of
the contest โ they'd apply unconditionally, whichever candidate the browser actually took, so the variable and the
property could end up disagreeing. Bamboo will not guess: it warns and applies your preferred candidate on its own.
โ ๏ธ `lineClamp: fallback(none, 3)` does not resolve to a single declaration per candidate,
so no fallback was emitted. Only `none` was applied.
The same is true of tokens that expand, like the color-opacity modifier: color: 'fallback(red.300/50, red)' is
refused, because red.300/50 emits a --mix-color variable alongside color. Write the two declarations yourself if
you need that.
When you don't need it
If you use LightningCSS, don't hand-write vendor-prefix or color-space fallbacks. It already generates those from your browser targets, and it does a more thorough job:
css({ width: 'stretch', color: 'oklch(70% 0.1 200)' })
/* LightningCSS, targeting older browsers */
.w_stretch {
width: -webkit-fill-available;
width: -moz-available;
width: stretch;
}
.c_oklch\(70\%_0\.1_200\) {
color: #40b1b7;
color: color(display-p3 0.381906 0.685023 0.710512);
color: lab(66.1711% -31.3595 -12.905);
}
Reach for fallback(...) when the fallback is a different design decision, not a polyfill โ something no optimizer
can infer for you:
| Case | Why a tool can't do it |
|---|---|
fallback(100dvh, 100vh) | 100vh is not equivalent to 100dvh; you chose to accept it |
fallback(-webkit-grab, grab, move) | move is a different cursor, not a prefixed spelling of grab |
fallback(stretch, 100%) | 100% behaves differently; it's your call that it's close enough |
LightningCSS also prunes candidates it can prove are unreachable for your targets โ a 100vh fallback disappears
from a build that only targets browsers with dvh. It prunes on its own understanding of the values, so it can drop a
candidate at every target level when it considers it redundant. The default PostCSS pipeline keeps every candidate
exactly as written.
Only a value that is entirely one fallback(...) call is treated as a fallback list. 1px solid fallback(red, blue) is left alone, because a candidate list has no meaning as part of a larger value.
When you get it wrong
Anything Bamboo can recognise as a broken fallback(...) is reported at build time and the declaration is dropped,
rather than written out as text that isn't CSS:
fallback(100dvh, 100vhโ an unbalanced(or[can't be parsed as a candidate list.fallback(fallback(a, b), c)โ nesting has no meaning; group the candidates in one call instead.- Candidates that don't resolve to a single declaration โ as above.
Two mistakes it cannot catch, because the value is an ordinary string as far as Bamboo and TypeScript are concerned. Both reach the stylesheet verbatim and are ignored by the browser:
- A misspelled name โ
fallbacks(...), orfallback(...)used as part of a larger value likecalc(fallback(a, b))or1px solid fallback(red, blue). UnderstrictValuesa misspelling is a type error; the embedded forms are not. - The candidates themselves aren't type-checked.
fallback(red.300, rd)typechecks even understrictValues, because the whole thing is one string to TypeScript. The[...]escape hatch has the same property โ theno-escape-hatchlint rule does still catch it, since it parses each candidate individually.
TypeScript
Use the SystemStyleObject type if you want to type your styles.
import { css } from '../styled-system/css'
import type { SystemStyleObject } from '../styled-system/types'
const styles: SystemStyleObject = {
color: 'red',
}
Property conflicts
When you combine shorthand and longhand properties, Bamboo will resolve the styles in a predictable way. The shorthand property will take precedence over the longhand property.
import { css } from '../styled-system/css'
const styles = css({
paddingTop: '20px',
padding: '10px',
})
The styles generated at build time will look like this:
@layer utilities {
.p_10px {
padding: 10px;
}
.pt_20px {
padding-top: 20px;
}
}
Global vars
You can use the global.vars property to define global
CSS variables (opens in a new tab) or custom CSS
@property (opens in a new tab) definitions.
Bamboo will automatically generate the corresponding CSS variables and suggest them in your style objects.
They will be generated in the cssVarRoot near your tokens.
This can be especially useful when using a 3rd party library that provides custom CSS variables, like a popper library
that exposes a --popper-reference-width.
bamboo.config.ts
import { defineConfig } from '@bamboocss/dev'
export default defineConfig({
// ...
global: {
vars: {
'--popper-reference-width': '4px',
// you can also generate a CSS @property
'--button-color': {
syntax: '<color>',
inherits: false,
initialValue: 'blue',
},
},
},
})Note: Keys defined in global.vars will be available as a value for every utilities, as they're not bound to token
categories.
import { css } from '../styled-system/css'
const className = css({
'--button-color': 'colors.red.300',
// ^^^^^^^^^^^^ will be suggested
backgroundColor: 'var(--button-color)',
// ^^^^^^^^^^^^^^^^^^ will be suggested
})