Config Recipe
Shared recipes in your theme. defineRecipe for one element, defineSlotRecipe for parts. Named classes, JIT emit.
A config recipe is a defineRecipe (or defineSlotRecipe) call that lives in your theme, not in a component file.
Register it, run codegen, then import the generated function from styled-system/recipes.
That gets you named classes, .button--size_lg, instead of a pile of separate atomic classes.
Panda only emits a variant's CSS once it actually sees that variant called somewhere in your source.
Defining the recipe
The authoring shape is the same as cva. The extra field that matters is className, which becomes the CSS stem.
button.recipe.ts
import { defineRecipe } from '@pandacss/dev'
export const buttonRecipe = defineRecipe({
className: 'button',
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'
}
})Adding it to the theme
Register it under a theme key. That key is the import name and the name extract looks for.
panda.config.ts
import { defineConfig } from '@pandacss/dev'
import { buttonRecipe } from './button.recipe'
export default defineConfig({
theme: {
extend: {
recipes: {
button: buttonRecipe
}
}
}
})After pnpm panda codegen:
import { button } from '../styled-system/recipes'
;<button className={button({ visual: 'solid', size: 'lg' })}>Click me</button>Extending a preset recipe
If the recipe comes from a preset, override it under theme.extend.recipes. You can change className, base,
variants, and jsx. See Extending a preset.
export default defineConfig({
theme: {
extend: {
recipes: {
button: {
className: 'btn',
base: { rounded: 'full' },
jsx: ['Button', 'LinkButton']
}
}
}
}
})CSS Output
Panda emits a variant only when it can see a literal: button({ size: 'lg' }), a ternary whose both branches fold, or
<Button size="lg" /> on a tag it treats as this recipe. The first use also emits base plus defaultVariants.
With the default separator (_), the classes look like this:
button
button--visual_solid
button--size_lg
@layer recipes.base {
.button {
display: inline-flex;
}
}
@layer recipes.variants {
.button--size_lg {
padding-inline: var(--spacing-5);
font-size: var(--font-sizes-md);
}
}hash, prefix, and separator change that string. They are baked into recipes/runtime.ts at codegen, so you re-run
codegen after flipping them.
Compounds are named classes in recipes.compound_variants, not leftover atoms. See
compound variants.
button({ size }) and <Button size={size} /> only emit defaultVariants. The function still returns a class name at
runtime. If that class was never written to the sheet, nothing paints. Pre-generate the missing values with
dynamic variants.
How the recipe is named
The import, the CSS class, and the JSX tag can all be different words. Mixing them up is the usual reason styles vanish.
Theme key
The key you register under theme.recipes is the import name and the name of the generated types.
export default defineConfig({
theme: {
extend: {
recipes: {
button: buttonRecipe
}
}
}
})import { button, type ButtonVariant } from '../styled-system/recipes'className
className on the recipe is only the CSS stem. It does not rename the import. Omit it and the stem falls back to the
theme key.
export const buttonRecipe = defineRecipe({
className: 'btn'
// ...
}).btn {
}
.btn--size_sm {
}JSX name
Panda connects a JSX tag to the recipe by the component name, not by the import. For theme key button, that
default name is Button.
<Button size="lg" />If you wrap that in another component with a different name, list both on jsx. jsx replaces the default list, so
keep Button if you still render it.
export function LinkButton(props) {
return <Button {...props} />
}export const buttonRecipe = defineRecipe({
className: 'btn',
jsx: ['Button', 'LinkButton']
})More on wrappers and missed tags: Tracking JSX.
Using raw()
On cva and sva, raw() returns the
resolved style object. Config recipes do not do that.
raw() is an identity helper: it returns the props you passed, so the extractor can see them.
button.raw({ size: 'sm' })
// { size: 'sm' }css(button.raw({ size: 'sm' }), { color: 'red.500' }) does not include the recipe styles. Merge the class string
with atomic CSS instead. Utilities sit above @layer recipes, so the extra styles win.
import { css, cx } from '../styled-system/css'
import { button } from '../styled-system/recipes'
cx(button({ size: 'sm' }), css({ color: 'red.500' }))Exported Types
Generated names follow the theme key, not className.
import { button, type ButtonVariant, type ButtonVariantProps } from '../styled-system/recipes'
type ButtonVariant = {
size?: 'sm' | 'lg'
visual?: 'solid' | 'outline'
}If the recipe has compounds, ButtonVariantProps is literals only. ConditionalValue goes away.
Breakpoint objects (size: { base: 'sm', md: 'lg' }) are responsive variants.
They only work when the recipe has no compoundVariants.
Helpers
button.variantKeys
// ['visual', 'size']
button.variantMap
// { visual: ['solid', 'outline'], size: ['sm', 'lg'] }
button.splitVariantProps({ size: 'sm', onClick() {} })
// [{ size: 'sm' }, { onClick() {} }]
button.getVariantProps({ size: 'lg' })
// { visual: 'solid', size: 'lg' }
button.raw({ size: 'sm' })
// { size: 'sm' }splitVariantProps is the one you want in a wrapper, so onClick never looks like a variant. variantMap is handy for
Storybook argTypes. merge(other) combines two recipes. getVariantProps fills in defaultVariants. raw() is the
identity helper above, not a style object.
Config Slot Recipe
Parts use defineSlotRecipe under theme.slotRecipes. Same contract as above: theme key, named classes, emit only what
extract sees. The function returns a slot map, not one class string.
The slot shape (slots, per-slot base / css) is the same as sva.
checkbox.recipe.ts
import { defineSlotRecipe } from '@pandacss/dev'
export const checkboxRecipe = defineSlotRecipe({
className: 'checkbox',
slots: ['root', 'control', 'label'],
base: {
root: { display: 'flex', alignItems: 'center', gap: '2' },
control: { borderWidth: '1px', borderRadius: 'sm' },
label: { marginStart: '2' }
},
variants: {
size: {
sm: { control: { boxSize: '8' }, label: { fontSize: 'sm' } },
md: { control: { boxSize: '10' }, label: { fontSize: 'md' } }
}
},
defaultVariants: { size: 'sm' }
})panda.config.ts
import { defineConfig } from '@pandacss/dev'
import { checkboxRecipe } from './checkbox.recipe'
export default defineConfig({
theme: {
extend: {
slotRecipes: {
checkbox: checkboxRecipe
}
}
}
})import { checkbox } from '../styled-system/recipes'
const classes = checkbox({ size: 'md' })
// classes.root, classes.control, classes.labelDo not wrap a slot recipe with styled(). Put the parts on JSX with slot recipe
context.
Config slot classes are named, not atomic:
checkbox__root
checkbox__control
checkbox__root--size_md
@layer recipes.slots.base {
.checkbox__root {
display: flex;
}
}
@layer recipes.slots.variants {
.checkbox__root--size_md {
/* … */
}
}Compounds emit per-slot classes in recipes.slots.compound_variants. See
compound variants.