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:
| Helper | What it does |
|---|---|
variantKeys | The variant names, e.g. ['visual', 'size'] |
variantMap | Variant 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 |
config | The 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.