Slot Recipes
Learn how to style multiple parts components with slot recipes.
When using cva or defineRecipe might be enough for simple cases, slot recipes are a better fit for more complex
cases.
A slot recipe consists of these properties:
slots: An array of component parts to stylebase: The base styles per slotvariants: The different visual styles for each slotdefaultVariants: The default variant for the componentcompoundVariants: The compound variant combination and style overrides for each slot.
Credit: This API was inspired by multipart components in Chakra UI (opens in a new tab) and slot variants in Tailwind Variants (opens in a new tab)
See the comparison table between inline recipes (cva) and config recipes here.
The same comparison applies to sva and slot recipes.
Inline Slot Recipe (or sva)
The sva function is a shorthand for creating a slot recipe next to the component rather than in your config. It takes
the same arguments as cva but returns a slot recipe instead.
Defining the Recipe
checkbox.recipe.ts
import { sva } from '../styled-system/css'
const checkbox = sva({
slots: ['root', 'control', 'label'],
base: {
root: { display: 'flex', alignItems: 'center', gap: '2' },
control: { borderWidth: '1px', borderRadius: 'sm' },
label: { marginStart: '2' },
},
variants: {
size: {
sm: {
control: { width: '8', height: '8' },
label: { fontSize: 'sm' },
},
md: {
control: { width: '10', height: '10' },
label: { fontSize: 'md' },
},
},
},
defaultVariants: {
size: 'sm',
},
})Using the recipe
The returned value from sva is a function that can be used to apply the recipe for each component part. Here's an
example of how to use the checkbox recipe:
Checkbox.tsx
import { css } from '../styled-system/css'
import { checkbox } from './checkbox.recipe'
const Checkbox = () => {
const classes = checkbox({ size: 'sm' })
return (
<label className={classes.root}>
<input type="checkbox" className={css({ srOnly: true })} />
<div className={classes.control} />
<span className={classes.label}>Checkbox Label</span>
</label>
)
}Bamboo compiles each selected slot to shared declaration atoms. Slot and recipe names do not enter class identity, so
the same display: flex, border, or typography declaration is reused across slot recipes, ordinary recipes, and css()
calls.
@layer utilities {
._a {
gap: var(--spacing-2);
}
._b {
display: flex;
}
._c {
align-items: center;
}
._d {
border-width: 1px;
}
._e {
border-radius: var(--radii-sm);
}
._f {
margin-inline-start: var(--spacing-2);
}
}
The selected slot object contains only the atoms each slot needs. Finite dynamic selections become a lookup table of precompiled slot objects; no slot-specific recipe rule is required.
Compound Variants
Compound variants are a way to apply style overrides to a slot based on the combination of variants.
Let's say you want to apply a different border color to the checkbox control based on its size and the isChecked
variant, here's how to do it:
checkbox.recipe.ts
import { sva } from '../styled-system/css'
const checkbox = sva({
slots: ['root', 'control', 'label'],
base: {...},
variants: {
size: {
sm: {...},
md: {...}
},
isChecked: {
true: { control: {}, label: {} }
}
},
compoundVariants: [
{
size: 'sm',
isChecked: true,
css: {
control: { borderColor: 'green.500' }
}
}
],
defaultVariants: {...}
})Targeting slots
Vite atoms deliberately carry no slot or recipe identity. Add a semantic data attribute when one slot must target another in the DOM.
Let's say you want to apply a different border color to the button text directly from the root slot. Here's how you
would do it:
import { sva } from '../styled-system/css'
const button = sva({
slots: ['root', 'text'],
base: {
root: {
bg: 'blue.500',
_hover: {
// v--- π― this will target the `text` slot
'& [data-slot="text"]': {
color: 'white',
},
},
},
},
})
const classes = button()
;<button className={classes.root}>
<span data-slot="text" className={classes.text} />
</button>
The selector is stable across compact-name modes and does not couple application behavior to generated CSS names.
TypeScript Guide
Bamboo provides a RecipeVariantProps type utility that can be used to infer the variant properties of a slot recipe.
This is useful when you want to use the recipe in JSX and want to get type safety for the variants.
import { sva, type RecipeVariantProps } from '../styled-system/css'
const checkbox = sva({...})
export type CheckboxVariants = RecipeVariantProps<typeof checkbox>
// => { size?: 'sm' | 'md', isChecked?: boolean }
Usage in JSX
A slot recipe returns a record of classes rather than a single class, so each slot picks its own. The component from Using the recipe takes its variants as props:
import { checkbox, type CheckboxVariants } from './checkbox.recipe'
const Checkbox = (props: CheckboxVariants) => {
const classes = checkbox(props)
// ...markup unchanged
}
Styling JSX Compound Components
A slot recipe call returns a record of class strings, one per slot:
const classes = checkbox({ size: 'md' })
// { root: '_a _b _c', control: '_d _e', label: '_f' }
That record is the only thing a compound component's parts need. There is no createStyleContext β what it delivered
was a wrapper component per slot, and a record of strings needs no wrapper.
The root renders every part
Most components are this shape, and it needs no context at all:
checkbox.tsx
import { type ReactNode } from 'react'
import { checkbox, type CheckboxVariants } from './checkbox.recipe'
export const Checkbox = ({ size, children }: CheckboxVariants & { children: ReactNode }) => {
const classes = checkbox({ size })
return (
<label className={classes.root}>
<div className={classes.control} />
<span className={classes.label}>{children}</span>
</label>
)
}The consumer composes the parts
When the parts are exposed β <Checkbox.Control /> authored as a sibling of the element the variants were given to β
the record has to reach them. That is an ordinary context, and it is the whole pattern:
checkbox.tsx
'use client'
import { createContext, useContext, type ReactNode } from 'react'
import { checkbox, type CheckboxVariants } from './checkbox.recipe'
const SlotContext = createContext<ReturnType<typeof checkbox> | null>(null)
const useSlots = () => {
const slots = useContext(SlotContext)
if (!slots) throw new Error('A Checkbox part must render inside <Checkbox>')
return slots
}
export const Checkbox = ({ size, children }: CheckboxVariants & { children: ReactNode }) => {
const slots = checkbox({ size })
return (
<SlotContext.Provider value={slots}>
<label className={slots.root}>{children}</label>
</SlotContext.Provider>
)
}
Checkbox.Control = () => <div data-slot="control" className={useSlots().control} />
Checkbox.Label = ({ children }: { children: ReactNode }) => (
<span data-slot="label" className={useSlots().label}>
{children}
</span>
)Carry the class record rather than the variants. Both work β a part can equally read size from a context and call
checkbox({ size }).control itself β but the record is one call for the whole component instead of one per part, and
each part is then a property read.
A part rendered through a portal needs nothing extra: the record is plain strings, and React context crosses a portal.
Put the recipe's result in the context, never the recipe itself. An inline sva declaration is erased at build
time, so its binding has nothing behind it at runtime: checkbox({ size }) compiles, including from another module,
while <SlotContext.Provider value={checkbox}> fails the build with a runtime-binding diagnostic pointing at the
read.
data-slot is not required, but generated class names are opaque atoms carrying no slot identity, so it is what
application CSS or a test selects a part by β see Targeting slots.
Legacy named-recipe scoping
No supported Bamboo integration emits the model documented in this section. It explains @scope found in output from
older extraction-only releases; do not design new components around it. Vite returns complete shared atom strings for
each selected slot, and scopeRoots has no effect on compiled output.
Older extraction-only output applied the behavior below to both sva() and config slot recipes.
Only the root takes variants
A recipe that declares a slot named root is scoped: only the root takes variants, and every other slot is a constant
string.
// a config slot recipe exposes each slot on the recipe
checkbox.root({ size: 'md' }) // 'checkbox__root checkbox__root--size_md'
checkbox.control // 'checkbox__control' β a property, not a call
// an inline sva returns them all from one call
const classes = checkbox({ size: 'md' })
classes.root // 'checkbox__root checkbox__root--size_md'
classes.control // 'checkbox__control'
That is possible because a non-root slot's variant styles are not emitted as a class the slot has to carry. They are emitted as rules scoped by the class the root already carries:
/* not this β the control would have to be told which size it is */
.checkbox__control--size_md {
width: 10;
}
/* but this β the root already says so */
@scope (.checkbox__root--size_md) to (.checkbox__root) {
.checkbox__control {
width: 10;
}
}
So a slot deep inside a tree needs nothing delivered to it. No provider, no context, no wrapper per slot β and it works the same in React, Vue, Solid or a plain template.
to (.checkbox__root) bounds the scope at the next nested instance, so an outer size="md" does not reach the control
of a checkbox nested inside it.
Precedence is unaffected. The scoped selector is more specific than the slot's base rule, but specificity never crosses
a cascade layer β a consumer's css() output sits in utilities and still wins.
If the enclosing slot is called something else, name it with scopeRoots:
defineSlotRecipe({
className: 'menu',
slots: ['trigger', 'positioner', 'item'],
scopeRoots: ['positioner'],
variants: { size: { sm: { item: { padding: '2' } } } },
})
Components that span a portal
A portal is a real break in the tree, and no CSS mechanism crosses one. A <Select> occupies two disjoint subtrees
β the trigger side under root, the listbox side under a portaled positioner β and a variant writes styles into both.
One anchor can only ever reach one of them, and the half it misses gets variant rules that can never match. The base
styles still apply, so the component renders nearly right, which is harder to notice than a total failure.
Name both:
defineSlotRecipe({
className: 'select',
slots: ['root', 'trigger', 'positioner', 'content', 'item'],
scopeRoots: ['root', 'positioner'],
variants: { size: { lg: { trigger: { h: '11' }, item: { px: '3' } } } },
})
Each named slot takes variant props; every other slot stays a constant:
<div className={select.root({ size })}> {/* anchor β takes the variant */}
<button className={select.trigger} /> {/* constant β reached by the scope */}
</div>
<Portal>
<div className={select.positioner({ size })}> {/* anchor β takes the variant */}
<div className={select.item} /> {/* constant β reached by the scope */}
</div>
</Portal>
The second anchor still needs the variant
An anchor is callable, and that is not a detail you can ignore: the variant has to reach every anchor. Written flat,
as above, size is in scope at both. In a real compound component it is not β the consumer authors Select.Positioner
as a sibling of Select.Root, so it never sees the props the root was given:
const Select = ({ size, children }) => <div className={select.root({ size })}>{children}</div>
Select.Positioner = ({ children }) => (
<Portal>
<div className={select.positioner({ size })}>{children}</div> {/* β `size` is not in scope here */}
</Portal>
)
So a portaled component keeps one context β for its anchors, not for its slots:
const SizeContext = createContext('md')
const Select = ({ size = 'md', children }) => (
<SizeContext.Provider value={size}>
<div className={select.root({ size })}>{children}</div>
</SizeContext.Provider>
)
Select.Positioner = ({ children }) => (
<Portal>
<div className={select.positioner({ size: useContext(SizeContext) })}>{children}</div>
</Portal>
)
// every other slot needs nothing
Select.Trigger = (props) => <button className={select.trigger} {...props} />
Select.Item = (props) => <div className={select.item} {...props} />
That is the whole cost: one delivery per anchor, and the count is the number of subtrees the component occupies β two β
rather than the number of slots, which for a real select is fifteen. A component with no portal has one anchor, which
is the element receiving the props already, so it needs no context at all.
You never describe the DOM. The build emits each non-anchor slot's variant rules under every anchor, and only the anchor that is genuinely an ancestor matches at runtime:
@scope (.select__root--size_lg) to (.select__root) {
.select__trigger {
height: 11;
} /* matches */
.select__item {
padding-inline: 3;
} /* never matches β item is not under root */
}
@scope (.select__positioner--size_lg) to (.select__positioner) {
.select__trigger {
height: 11;
} /* never matches */
.select__item {
padding-inline: 3;
} /* matches */
}
Read scopeRoots as a cost control, not a description of the tree. Emitting every slot's variant rules under every
slot would be correct with nothing declared at all β it is just quadratic in slot count. Naming the enclosing slots
prunes that to one copy per anchor. On a 15-slot recipe with two anchors that is +84% raw CSS and +24% gzipped against a
single anchor, and it needs no runtime channel: the alternative, giving those eight slots their own variant classes,
gzips larger and still has to deliver them.
If one anchor is nested inside another and both match a slot, @scope picks by proximity β the nearer anchor wins.
A slot under no anchor is still unreachable, and nothing at build time can detect that; reachability is a fact about
the DOM. slotsAffectedBy says which slots a variant writes to, for whatever still needs threading by hand.
Catching an unreachable slot in development
That last failure is quiet β the slot keeps its base styles, so it renders nearly right. auditSlotScopes turns it
into a console warning naming the slot:
import { auditSlotScopes } from '../styled-system/css'
import { select } from '../styled-system/recipes'
if (process.env.NODE_ENV !== 'production') {
auditSlotScopes([select], { observe: true })
}
It looks for elements carrying a scoped slot's class that have no anchor above them, and warns:
[bamboo] select: the `item` slot is rendered outside every anchor (root), so its variant
styles cannot reach it. Add the enclosing slot to `scopeRoots`, or deliver the variant to
this slot by hand.
observe: true re-checks as the DOM changes, which matters because portaled content mounts after the first sweep β and
that is the case this exists to catch. It returns a function that stops observing.
Keep the call behind a NODE_ENV check so your bundler drops both it and the function from production. A recipe with
scopeRoots: [] is never reported: nothing is scoped, so no slot depends on where it is rendered.
Turning scoping off
A recipe whose slots are all siblings has no ancestor to scope by, and gets a variant class per slot automatically. Ask for that explicitly with an empty list:
scopeRoots: [] // every slot keeps its own variant class, and stays callable
Config Slot Recipe
Config slot recipes are declared in theme.slotRecipes, can be shared through presets, and get a generated typed module
to import. Under Vite their selected slots use the same shared atoms and finite decision tables as inline sva().
The config slot recipe takes the following additional properties:
className: Optional semantic metadata; it does not enter compiled declaration-atom identityjsx: An array of JSX components that use the recipe. Defaults to the uppercase version of the recipe namedescription: An optional description of the recipe (used in the js-doc comments)
Extraction-only output also accepts scopeRoots for its named-rule scoping model; Vite ignores it because selected slot
styles are returned directly as atoms.
Defining the recipe
To define a config slot recipe, import the defineSlotRecipe function
checkbox.recipe.ts
import { defineSlotRecipe } from '@bamboocss/dev'
export const checkboxRecipe = defineSlotRecipe({
className: 'checkbox',
description: 'The styles for the Checkbox component',
slots: ['root', 'control', 'label'],
base: {
root: { display: 'flex', alignItems: 'center', gap: '2' },
control: { borderWidth: '1px', borderRadius: 'sm' },
label: { marginStart: '2' },
},
variants: {
size: {
sm: {
control: { width: '8', height: '8' },
label: { fontSize: 'sm' },
},
md: {
control: { width: '10', height: '10' },
label: { fontSize: 'md' },
},
},
},
defaultVariants: {
size: 'sm',
},
})Adding recipe to config
To add the recipe to the config, youβd need to add it to the slotRecipes property of the theme
bamboo.config.ts
import { defineConfig } from '@bamboocss/dev'
import { checkboxRecipe } from './checkbox.recipe'
export default defineConfig({
//...
theme: {
extend: {
slotRecipes: {
checkbox: checkboxRecipe,
},
},
},
})Generate JS code
This generates a recipes folder the specified outdir which is styled-system by default. If Bamboo doesnβt
automatically generate your CSS file, you can run the bamboo codegen command.
You only need to import the recipes into the component files where you need to use them.
Using the recipe
To use the recipe, you can import the recipe from the <outdir>/recipes entrypoint and use it in your component. Bamboo
tracks the usage of the recipe and only generates CSS of the variants used in your application.
import { css } from '../styled-system/css'
import { checkbox } from '../styled-system/recipes'
const Checkbox = () => {
const classes = checkbox({ size: 'sm' })
return (
<label className={classes.root}>
<input type="checkbox" className={css({ srOnly: true })} />
<div className={classes.control} />
<span className={classes.label}>Checkbox Label</span>
</label>
)
}
The selected slots compile to the same shared utility atom pool as css() and inline recipes. Only atoms reachable from
the application graph are kept in a production Vite build.
@layer utilities {
._a {
gap: var(--spacing-2);
}
._b {
display: flex;
}
._c {
align-items: center;
}
._d {
border-width: 1px;
}
._e {
border-radius: var(--radii-sm);
}
._f {
margin-inline-start: var(--spacing-2);
}
}
TypeScript Guide
Every slot recipe ships a type interface for its accepted variants. You can import them from the styled-system/recipes
entrypoint.
For the checkbox recipe, we can import the CheckboxVariants type like so:
import React from 'react'
import type { CheckboxVariants } from '../styled-system/recipes'
type CheckboxProps = CheckboxVariants & {
children: React.ReactNode
value?: string
onChange?: (value: string) => void
}
One class that reaches its parts
A slot recipe hands you one class per slot, which you bind to each element. Sometimes you want the opposite: a single
class on the root that styles its children by selector, so there is nothing to bind. This pairs well with
ZagJs (opens in a new tab) and Ark-UI (opens in a new tab), which already mark their parts with data-part.
That needs no separate API β it is a regular recipe whose keys are selectors:
import { defineRecipe } from '@bamboocss/dev'
export const checkboxRecipe = defineRecipe({
className: 'checkbox',
description: 'A checkbox style',
base: {
'& [data-part="root"]': { display: 'flex', alignItems: 'center', gap: '2' },
'& [data-part="control"]': { borderWidth: '1px', borderRadius: 'sm' },
'& [data-part="label"]': { marginStart: '2' },
},
variants: {
size: {
sm: {
'& [data-part="control"]': { width: '8', height: '8' },
'& [data-part="label"]': { fontSize: 'sm' },
},
md: {
'& [data-part="control"]': { width: '10', height: '10' },
'& [data-part="label"]': { fontSize: 'md' },
},
},
},
defaultVariants: {
size: 'sm',
},
})
A defineParts helper used to key these objects by part name instead of by selector. It was removed: bamboo models a
multi-part component one way, as a slot recipe, and the selector form above needs no API at all.
Zag and Ark generate their selectors, so if you build parts from an anatomy rather than writing them out, keep a
helper of your own next to the recipe β it is a few lines and it belongs to your codebase, not to the framework:
const toParts =
<T extends Record<string, { selector: string }>>(anatomy: T) =>
(config: Partial<Record<keyof T, SystemStyleObject>>): SystemStyleObject =>
Object.fromEntries(Object.entries(config).map(([part, styles]) => [anatomy[part].selector, styles]))