---
title: Slot Recipes
description: 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 style
- `base`: The base styles per slot
- `variants`: The different visual styles for each slot
- `defaultVariants`: The default variant for the component
- `compoundVariants`: The compound variant combination and style overrides for each slot.

> **Credit:** This API was inspired by multipart components in
> [Chakra UI](https://chakra-ui.com/docs/styled-system/component-style) and slot variants in
> [Tailwind Variants](https://tailwind-variants.org)

[See the comparison table between inline recipes (`cva`) and `config recipes` here.](/docs/concepts/recipes#should-i-use-an-inline-or-config-recipe)
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

```jsx filename="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:

```jsx filename="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.

```css
@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:

```jsx filename="checkbox.recipe.ts" {14-22}
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:

```tsx
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.

```tsx
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](#using-the-recipe) takes its variants as props:

```tsx
import { checkbox, type CheckboxVariants } from './checkbox.recipe'

const Checkbox = (props: CheckboxVariants) => {
  const classes = checkbox(props)
  // ...markup unchanged
}
```

## Variant scoping in extraction-only output

This section applies only to CLI/PostCSS extraction without the Vite source compiler. Vite does not emit named slot
rules or `@scope`: every selected slot is a complete shared atom string, and a finite dynamic selection returns a
precompiled slot object. `scopeRoots` has no effect on Vite output.

For extraction-only output, the behavior below applies to both `sva()` and [config slot recipes](#config-slot-recipe).

### 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.

```jsx
// 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:

```css
/* 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](/docs/concepts/cascade-layers) — a consumer's `css()` output sits in `utilities` and still wins.

If the enclosing slot is called something else, name it with `scopeRoots`:

```ts
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:

```ts
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:

```jsx
<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:

```jsx
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:

```jsx
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:

```css
@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:

```js
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:

```ts
scopeRoots: [] // every slot keeps its own variant class, and stays callable
```

### Styling JSX Compound Components

With scoping, a compound component needs no context and no wrapper per part — a slot's class is a constant, so each part
reads its own:

```jsx
// no provider, no context, no wrapper per part
const Checkbox = ({ size, children }) => <label className={checkbox.root({ size })}>{children}</label>
Checkbox.Control = () => <div className={checkbox.control} />
Checkbox.Label = ({ children }) => <span className={checkbox.label}>{children}</span>
```

The one shape that still needs a context is a component spanning a portal, and it needs it for its second _anchor_
rather than for its slots — see [The second anchor still needs the variant](#the-second-anchor-still-needs-the-variant).
In React that makes the file a client component, so add `'use client'` at the top.

## 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`: Authoring metadata used by extraction-only integrations; it does not enter Vite atom identity
- `jsx`: An array of JSX components that use the recipe. Defaults to the uppercase version of the recipe name
- `description`: 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

```jsx filename="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`

```jsx filename="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.

```js
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.

```css
@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:

```ts
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](https://zagjs.com/) and [Ark-UI](https://ark-ui.com/), which already mark their parts with `data-part`.

That needs no separate API — it is a regular recipe whose keys are selectors:

```ts
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',
  },
})
```

<Callout type="info">
  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:

```ts
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]))
```

</Callout>
