concepts
slot recipes

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 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 (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 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)

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