Skip to content

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

Write recipes

Atomic Recipe

Colocated multi-variant styles with cva. Every variant emits as atomic CSS when you define the recipe.

An atomic recipe is a cva call: base styles and named variants, next to the component. Every variant turns into CSS as soon as you save the file, whether or not your app ever calls it.

button({ size: 'sm', visual: 'solid' })

That returns a class string, one class per style: d_inline-flex bg_blue.500 px_3 ... Panda's cva generates these classes for you, so you never write them by hand.

The name cva comes from Class Variance Authority (opens in a new tab), a popular library for the same pattern. Panda's version resolves a variant like bg: 'blue.500' against a color token, not a raw CSS value.

Sharing this across a whole design system instead? Use a config recipe.

Defining the recipe

Put whatever every look shares in base. Name each look under variants. Set defaultVariants so button() alone still renders a fully styled button.

import { cva } from '../styled-system/css'
 
const button = cva({
  base: {
    display: 'inline-flex',
    alignItems: 'center',
    rounded: 'md',
    fontWeight: 'semibold'
  },
  variants: {
    visual: {
      solid: { bg: 'blue.500', color: 'white' },
      outline: { borderWidth: '1px', borderColor: 'blue.500', color: 'blue.500' }
    },
    size: {
      sm: { px: '3', py: '1.5', fontSize: 'sm' },
      lg: { px: '5', py: '3', fontSize: 'md' }
    }
  },
  defaultVariants: {
    visual: 'solid',
    size: 'sm'
  }
})

If you need a style that only applies when two variants combine, say visual: 'outline' with size: 'lg', reach for compound variants.

Boolean variants

Some variants are just an on/off switch, like outlined below, no named options needed. Panda converts between the two.

const button = cva({
  base: { rounded: 'md' },
  variants: {
    outlined: {
      true: { borderWidth: '1px', borderColor: 'gray.300' }
    }
  }
})
<button className={button({ outlined: true })}>Save</button>

Using the recipe

Atomic recipes like button() only knows the variants you gave it. It does not take any css or className.

export function Button({ visual, size, children }) {
  return <button className={button({ visual, size })}>{children}</button>
}

This means an extra key like color: 'red.500' gets ignored. To merge styles, use raw() or wrap the recipe with styled().

CSS Output

You do not need a literal button({ size: 'lg' }) somewhere in the app for lg to exist.

Everything in base, every variant option, every compoundVariants entry lands in @layer utilities as atomic classes, one class per CSS property, shared across your app:

@layer utilities {
  .d_inline-flex {
    display: inline-flex;
  }
  .bg_blue\.500 {
    background-color: var(--colors-blue-500);
  }
  .px_3 {
    padding-inline: var(--spacing-3);
  }
}

That only works because Panda reads your source. It doesn't run it. A ternary still works, since both sides are literal values sitting right there:

const button = cva({
  variants: {
    size: {
      // Panda emits CSS for both 'xs' and 'sm', even though only one runs at a time
      sm: { fontSize: isCompact ? 'xs' : 'sm' }
    }
  }
})

It breaks down for a config built at runtime, or pulled from something that isn't a plain object literal: Panda can't see inside a function call, so none of it reaches your CSS.

// none of these variants show up in the CSS, Panda can't see inside the function
const button = cva(getButtonConfig(theme))
⚠️

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

Using raw()

Say you want the recipe's styles plus one override, such as a red color for an error state. You can't pass that to button(). Instead, use button.raw(). This gives you the resolved style object instead of a class string, so you can drop it into css() next to the override.

import { css, cx } from '../styled-system/css'
 
css(button.raw({ size: 'sm' }), { color: 'red.500' })

A wrapper that takes a css prop does the same thing:

export function Button({ visual, size, css: cssProp, children }) {
  return <button className={css(button.raw({ visual, size }), cssProp)}>{children}</button>
}

cx() only joins class strings. It doesn't resolve conflicts the way css() does. Reach for it when the extra class doesn't touch a property the recipe already sets, like a conditional class:

cx(button({ size: 'sm' }), isDisabled && css({ opacity: '0.5' }))

If the extra style does conflict with something in the recipe, like color above, use raw() and css() instead so Panda can actually merge them.

Exported types

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

import { cva, type RecipeVariant, type RecipeVariantProps } from '../styled-system/css'
 
type ButtonVariant = RecipeVariant<typeof button>
// { visual: 'solid' | 'outline'; size: 'sm' | 'lg' }
 
type ButtonProps = RecipeVariantProps<typeof button>
// { visual?: 'solid' | 'outline'; size?: 'sm' | 'lg' }

Helpers

cva returns a function, plus a handful of extra properties:

HelperWhat it does
variantKeysThe variant names, e.g. ['visual', 'size']
variantMapVariant names mapped to their possible values, e.g. { visual: ['solid', 'outline'], size: ['sm', 'lg'] }
splitVariantProps(props)Splits an object into [variantProps, restProps], so a wrapper can tell size apart from onClick
getVariantProps(props)Fills in defaultVariants for any variant key you didn't pass
raw(props)Resolves to the style object, covered above under Using raw()
merge(other)Combines two cva recipes, including their variants and compound variants
configThe original object you passed to cva()

splitVariantProps is the one you want in a wrapper, so onClick never looks like a variant. variantMap is handy for Storybook argTypes.

Wrapping a headless or third-party component? Use Recipe context instead.

See also

Edit this page on GitHubView as markdown
Last updated on