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.
Scoping and scopeRoots
Older extraction-only releases scoped a slot's variant styles with @scope rules anchored at an enclosing slot, and
exposed each slot on the recipe β checkbox.root({ size }), checkbox.control β along with auditSlotScopes to report
slots rendered outside their anchor. None of that exists in a compiled build. Vite returns complete atoms for every
selected slot, emits no @scope, and does not read scopeRoots, so no slot depends on where it is rendered.
Select slots from the call instead: checkbox({ size }).control compiles, while checkbox.control reads the recipe
binding and fails the build with runtime-binding. scopeRoots is still accepted in a recipe config so existing
configs keep loading.
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)
scopeRoots is accepted and ignored β see Scoping and scopeRoots.
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 CheckboxVariantProps type like so:
import React from 'react'
import type { CheckboxVariantProps } from '../styled-system/recipes'
type CheckboxProps = CheckboxVariantProps & {
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]))