Skip to content

Want to skip the docs? Check out pandamastery.com - the best way to learn Panda CSS

Write recipes

Slot Recipe

Colocated multi-part styles with sva. Every slot style emits as atomic CSS when you define the recipe.

Some components are more than one element. A card has a root, a title, and a body. You still want one size prop to style every part.

<Card.Root size="lg">
  <Card.Title>Plan</Card.Title>
  <Card.Body>Everything in one place.</Card.Body>
</Card.Root>

sva is the recipe for that. You write it next to the component: slots, per-slot styles, named variants. Call it with { size: 'lg' } and you get a map of class strings, one per slot.

const card = sva({
  slots: ['root', 'title', 'body'],
  // base, variants, ...
})
 
const classes = card({ size: 'lg' })
classes.root // Card.Root
classes.title // Card.Title
classes.body // Card.Body

Panda extracts the sva({...}) definition, not the call sites.

If the recipe is shared across apps or a preset, use defineSlotRecipe instead.

Defining the recipe

base and each variant option are per slot, not a flat style object. Compounds use compoundVariants, with css written per slot.

import { sva } from '../styled-system/css'
 
const card = sva({
  slots: ['root', 'title', 'body'],
  className: 'card',
  base: {
    root: {
      rounded: 'lg',
      borderWidth: '1px',
      borderColor: 'gray.200',
      bg: 'white',
      p: '4'
    },
    title: { fontWeight: 'semibold', mb: '2' },
    body: { color: 'gray.600', fontSize: 'sm' }
  },
  variants: {
    size: {
      sm: {
        root: { p: '3' },
        title: { fontSize: 'md' },
        body: { fontSize: 'xs' }
      },
      lg: {
        root: { p: '6' },
        title: { fontSize: 'xl' },
        body: { fontSize: 'md' }
      }
    }
  },
  defaultVariants: { size: 'sm' }
})

slots is required in the types. If you omit it, the engine infers names from keys in base, variants, and compounds. List them anyway, so a typo is a type error instead of a silent extra slot.

compoundVariants is for styles that apply only when two variants match. That has its own page: compound variants.

Boolean variants use 'true' and 'false' as keys in the definition. At the call site you pass a real boolean.

Using the recipe

export function Card({ size, title, children }) {
  const classes = card({ size })
  return (
    <div className={classes.root}>
      <h2 className={classes.title}>{title}</h2>
      <div className={classes.body}>{children}</div>
    </div>
  )
}

card() does not take css or className. To merge extras, use raw() or wrap the parts with slot recipe context.

⚠️

Do not wrap a slot recipe with styled(). The factory expects a function that returns one class string. Put the parts on JSX with slot recipe context.

CSS Output

You do not need a literal card({ size: 'lg' }) somewhere in the app for lg to exist. Everything in base, every variant option, and every compound css lands in @layer utilities as atoms:

@layer utilities {
  .rounded_lg {
    border-radius: var(--radii-lg);
  }
  .p_4 {
    padding: var(--spacing-4);
  }
  .font_semibold {
    font-weight: var(--font-weights-semibold);
  }
}

A ternary inside the definition emits both branches, because both values are literals. A recipe object you build at runtime, or import as something that does not fold to an object, is invisible. Panda never ran the code. It only read the source.

⚠️

sva has no responsive variant props. card({ size: { base: 'sm', md: 'lg' } }) does not resolve. See responsive variants.

Setting className

className is optional. When you set it, each slot also gets a stable ${className}__${slot} class next to the atoms: card__root, card__title, card__body.

Targeting slots

Use those classes to style one part from another. Here the root’s hover changes the title:

const card = sva({
  slots: ['root', 'title', 'body'],
  className: 'card',
  base: {
    root: {
      _hover: {
        '& .card__title': { color: 'blue.500' }
      }
    }
  }
})

hash: true hashes those names. Target data-slot instead if you hash.

Using raw()

card() returns a map of class strings. card.raw() returns the resolved style object for each slot. Pick a slot and pass it into css() with other style objects. Later keys win.

import { css, cx } from '../styled-system/css'
 
css(card.raw({ size: 'sm' }).root, { borderColor: 'blue.500' })

A wrapper that takes a per-slot style prop does the same thing:

export function Card({ size, rootCss, title, children }) {
  const styles = card.raw({ size })
  return (
    <div className={css(styles.root, rootCss)}>
      <h2 className={css(styles.title)}>{title}</h2>
      <div className={css(styles.body)}>{children}</div>
    </div>
  )
}

Already have class strings? Skip raw() and concatenate:

const classes = card({ size: 'sm' })
cx(classes.root, css({ borderColor: 'blue.500' }))

Exported Types

RecipeVariant makes every key required. RecipeVariantProps makes them optional, which is what you want for JSX props.

import { sva, type RecipeVariant, type RecipeVariantProps } from '../styled-system/css'
 
type CardVariant = RecipeVariant<typeof card>
// { size: 'sm' | 'lg' }
 
type CardProps = RecipeVariantProps<typeof card>
// { size?: 'sm' | 'lg' }

Helpers

card.variantKeys
// ['size']
 
card.variantMap
// { size: ['sm', 'lg'] }
 
card.splitVariantProps({ size: 'sm', title: 'Hello' })
// [{ size: 'sm' }, { title: 'Hello' }]
 
card.getVariantProps({ size: 'lg' })
// { size: 'lg' }
 
card.raw({ size: 'sm' })
// { root: { ... }, title: { ... }, body: { ... } }

splitVariantProps is the one you want in a wrapper, so title never looks like a variant. variantMap is handy for Storybook argTypes. merge(other) combines two sva recipes. config is the original definition. raw() is covered above.

To wrap a tree of parts, use Slot recipe context.

See also

Edit this page on GitHubView as markdown
Last updated on