This is the architecture layer. What a token is called, which layer it lives in, and when a style graduates from a call site to a recipe. These choices are hard to reverse once a codebase has a few hundred call sites.
Two questions answer most of it:
- Does this value have meaning, or is it just a value? Meaning goes in
semanticTokens. - Does anyone outside this file need to vary this? If yes, it's a recipe. If no, it's
css().
Vocabulary
Use these words exactly. Panda's config keys are named after them, so a loose word here turns into the wrong config key later. Each entry says what to avoid and why.
Primitive token: a raw scale value under theme.tokens, named for what it is (blue.500, spacing.4). Avoid:
"variable" (Panda emits CSS variables from tokens, they are not the same thing), "base token" (reads as { base: … },
which is a condition).
Semantic token: a value under theme.semanticTokens, named for the role it plays (fg.muted, danger), and free
to change per condition. Avoid: "alias" (an alias implies one fixed target, a semantic token can hold a different
value per condition), "theme variable".
Composite style: a named bundle of properties under textStyles, layerStyles, or animationStyles. Avoid:
"preset" (a preset is a shareable config package in Panda, a different thing entirely), "mixin".
Recipe: a component's styles plus its variants, from cva in a file or theme.recipes in config. Avoid: "variant
function", "style object" (a style object is the plain argument you pass to css()).
Slot: one named part of a slot recipe, styled independently. Avoid: "part", "element", "sub-component". The config
key is slots, so any other word loses the thread.
Anatomy: the full list of slots a component exposes. It is public API. Avoid: "structure", "shape".
Variant axis: one key under variants (size, visual), holding the options along that axis. Avoid: "prop"
(props are what the consumer passes, an axis is what you define), "modifier".
Compound variant: a rule that fires when a specific combination of axes matches. Avoid: "combo variant", "multi-variant".
Pattern: a reusable layout function (stack, grid, container). Avoid: calling a pattern a component, and
calling a recipe a pattern. Panda ships both words with separate meanings and separate config keys.
Condition: a state or media selector key (_hover, _dark, md). Avoid: "modifier", "pseudo" (conditions cover
media queries and selectors too).
Step 1: read what exists
panda.config.ts:presets,theme,semanticTokens,conditions,strictTokens,designSystem.<outdir>/types/tokens.d.ts: every token name that currently resolves.<outdir>/specs/design-system.json: the same tokens with their values, conditions, recipes, and which preset each one came from. See The design system spec.<outdir>/recipes/: what's already a recipe.- Grep a few call sites for the property you're about to tokenize. If eight files already write
gray.600for secondary text, that's the semantic token you're missing.
Don't design in a vacuum. Presets (@pandacss/preset-base, @pandacss/preset-panda) already ship scales. Extend them
rather than replacing them.
The token layers
Four layers, each with a different job. Put a value in the highest layer that still describes it honestly.
1. Primitive tokens live in theme.tokens. Raw scale values with no opinion about use.
tokens: {
colors: { blue: { 500: { value: '#3b82f6' } } },
spacing: { 4: { value: '1rem' } },
}
Rules: a full scale, not the three shades you need today. Named by what it is (blue.500), never by what it's for.
Nothing in a primitive name should mention a component.
2. Semantic tokens live in theme.semanticTokens. What a value means, and how it changes per condition.
semanticTokens: {
colors: {
fg: { value: { base: '{colors.gray.900}', _dark: '{colors.gray.50}' } },
'fg.muted': { value: { base: '{colors.gray.600}', _dark: '{colors.gray.400}' } },
danger: { value: '{colors.red.600}' },
},
}
Rules:
- This is the only sane place to handle light/dark. Pair both values in one token; never branch
_darkat call sites you could have branched once here. - Name by role:
fg,fg.muted,bg.subtle,border,danger. Notgray600Text, notbuttonBlue. - A token used by exactly one component is a recipe value, not a semantic token. Promote it when the second consumer appears, not before.
- Reference primitives with
{colors.gray.900}. Don't paste hex into a semantic token.
3. Composite styles are textStyles, layerStyles, and animationStyles. Named bundles of properties that travel
together.
textStyles: {
heading: {
h1: { value: { fontSize: '2.5rem', fontWeight: 'bold', lineHeight: '1.2' } },
},
}
Use one when the same three-to-five properties always appear together and carry a name your team already says out loud. If it needs variants, it's a recipe instead.
4. Recipes are component styles with variants. See below.
Naming
- Role over appearance.
dangersurvives a rebrand;reddoesn't. - Dots for hierarchy:
fg,fg.muted,fg.subtle. Panda flattens them into one namespace, so the prefix is the group. - Pick one axis and stay on it. Mixing
bg.subtlewithsubtleBackgroundin the same theme costs more than either choice. - Watch keyword collisions. A token named
normalinlineHeightsshadows the CSS keyword. Rename the token.
Themes and color mode
Semantic tokens hold both values. Something still has to decide which one applies.
Color mode
With the base preset, _dark means "inside an element carrying the dark class". Toggle the class, every semantic
token flips, and no component knows it happened.
<html class="dark"></html>
To match a data attribute as well, redefine the condition. This is a config change, so run codegen after it.
conditions: {
extend: {
dark: '.dark &, [data-theme="dark"] &',
},
}
Don't reach for a useColorModeValue-style hook or branch in the component. The condition is the mechanism.
More than one theme
themes holds alternative token values behind one attribute. Override only what differs from the default, and keep both
modes on every value you override.
themes: {
gothic: {
semanticTokens: {
colors: {
canvas: { value: { base: '{colors.gray.100}', _dark: '{colors.black}' } },
accent: { value: { base: '{colors.purple.700}', _dark: '{colors.purple.300}' } },
},
radii: { control: { value: '{radii.none}' } },
},
},
}
<html data-panda-theme="gothic"></html>
Theme and mode are separate axes. data-panda-theme picks the theme, the dark class picks the mode inside it, and
they stack. A theme applies to the element carrying the attribute and everything under it, so you can put one theme on
<html> and a different one on a panel inside it.
Shipping themes
Panda emits nothing for themes until you say how to ship them. Two options, and they're independent.
In the main stylesheet. List them in staticCss.themes. Each becomes a block of variables in the tokens layer
holding only what it overrides. No runtime, one request, and every page pays for every theme.
staticCss: {
themes: ['matcha', 'gothic'],
}
On demand. <outdir>/themes carries each theme as its own JSON file, fetched when asked for. Choose this when there
are many themes, or the user picks one at runtime.
import { getTheme, injectTheme } from '../styled-system/themes'
const theme = await getTheme('gothic')
injectTheme(document.documentElement, theme)
injectTheme sets data-panda-theme on the element and appends a <style> with the theme's variables.
Deciding
- One look, light and dark: semantic tokens with
_dark. Nothemesblock. - A handful of fixed brands:
themesplusstaticCss.themes. - Many themes, or user-chosen at runtime:
themesplusgetTheme/injectTheme.
A theme that overrides most of the token set is usually a sign the semantic layer is too thin. Themes should override roles, not rebuild the scale.
The design system spec
The spec is opt-in. Pass --spec to panda codegen, panda build, or panda lib and it writes
<outdir>/specs/design-system.json. --spec=<file> puts it somewhere else. Without the flag, no file is written, and
panda dev doesn't take it at all.
It is the whole resolved system in one file, with no server and no MCP:
schemaVersion, andcategoriesmapping each token category to its slice ofpathspathsplustokens, giving every token's category andcssVarvalues, pairing each token to its resolved value (animations.spinisspin 1s linear infinite)conditions, with the real selector behind each name (_hoveris&:is(:hover, [data-hover]))sources, naming the preset or file each token came from, whichtokens[path].sourceindexes intothemes, and, when the project defines them,recipes,slotRecipes,patterns,textStyles,layerStyles,animationStyles,keyframes, andcolorPalettes. Empty sections are omitted.
Read it when you need a token's value, its CSS variable, or which preset defined it. For the cheaper question of whether
a name exists at all, <outdir>/types/tokens.d.ts is a union type and faster to grep.
If the file isn't there, the usual reason is that nobody passed --spec, not that codegen failed. Run
panda codegen --spec. Don't fall back to reading panda.config.ts instead: it holds what someone wrote, not what
Panda resolved after presets merged, and with @pandacss/preset-base and @pandacss/preset-panda in play most of the
token set never appears there.
If it is there but stale, it was written by an earlier run. Nothing refreshes it automatically, so
panda codegen --spec again.
Auditing a theme starts here. Tokens whose source is a preset are inherited, so a project's own additions are the ones
with a local source, and that difference is usually where the drift lives.
Writing a DESIGN.md from it
If a team wants a human-readable brief for agents, build it from this file rather than by hand. The spec supplies every
fact: token names, values, conditions, recipe variants, and provenance. What it cannot supply is intent, which is when
to reach for fg.muted over fg, which scales are off limits, and what the brand refuses to do.
Write the intent, generate the rest, and say in the file which spec run it came from. Don't hand-copy token values into
prose. They go stale the next time someone edits the theme, and regenerating is one panda codegen --spec.
A project that wants this regularly should put --spec on the build script rather than relying on someone remembering
the flag.
When a style becomes a recipe
A ladder. Climb one rung at a time, and only when the test passes.
css({ … }) at the call site. Where everything starts. Stay here while the style is used once.
↓ the same style object appears in three or more places
cva in the component file. A local recipe with variants, colocated with its only consumer.
import { cva } from '../styled-system/css'
const button = cva({
base: { px: '4', py: '2', rounded: 'md' },
variants: {
visual: { solid: { bg: 'accent' }, outline: { borderWidth: '1px' } },
size: { sm: { fontSize: 'sm' }, md: { fontSize: 'md' } },
},
defaultVariants: { visual: 'solid', size: 'md' },
})
↓ consumers outside this file need the variants, or the styles belong to the design system rather than the component
Config recipe. defineRecipe in panda.config.ts under theme.recipes. Now the variant API is part of the system,
types are generated, and the CSS is emitted from config.
↓ the component renders more than one element and each needs its own styles
Slot recipe. defineSlotRecipe under theme.slotRecipes, or sva in a file.
Don't climb a rung you don't need. A config recipe for a style used in one file is indirection with a typing cost.
When it's a slot recipe
Use slots when the component renders parts a consumer can style or replace independently: root, trigger, content, title, indicator. Signals:
- You're passing
classNamedown to inner elements to style them. - You wrote a nested selector reaching into a child component's markup.
- Two elements need to react to the same variant differently.
const card = sva({
slots: ['root', 'header', 'body'],
base: {
root: { rounded: 'lg', borderWidth: '1px' },
header: { px: '4', py: '3', borderBottomWidth: '1px' },
body: { px: '4', py: '4' },
},
variants: {
density: {
compact: { header: { py: '2' }, body: { py: '2' } },
},
},
})
Design the anatomy first. Name the slots before you write styles. The slot list is public API, so renaming one later breaks every consumer.
Converting a recipe to a slot recipe: list the elements the component renders, name each one, move each block of
styles under its slot, then move variants so each variant names the slots it touches. base and every variant go from a
flat style object to a map keyed by slot. Then switch consumers from createRecipeContext to createSlotRecipeContext.
Consuming recipes in JSX (v2)
createRecipeContext(recipe)returns{ withContext }, for acvarecipe.createSlotRecipeContext(recipe)returns{ withRootProvider, withProvider, withContext }, forsva.withRootProvideris for a root that doesn't render a slot of its own.
Both come from <outdir>/jsx. v1's single createStyleContext is gone.
Designing the variant API
- Name variants by intent, not implementation.
visual,size,tone. NotisBlue. - Always set
defaultVariants. Without them a consumer who passes nothing gets base styles only. A variant passed as a runtime prop emits base plus defaults only anyway. - Boolean variants are
true/falsekeys. Keep them for genuinely binary things. - Compound variants cost CSS. Each combination emits. A matrix of 4 visuals by 4 sizes by 3 tones is 48 potential
blocks. In v2 compound variants emit eagerly at build time. Set
optimize.smartCompoundVariants: trueto narrow the output to combinations actually used. - Prefer more variants over more compound variants. If you need a compound rule for most cells of the matrix, the axes are wrong.
- Responsive variant values work (
size={{ base: 'sm', md: 'md' }}), but only for extracted literals.
For exported components under isolatedDeclarations, annotate with the variant keys so the CSS stays out of the
.d.ts:
import type { RecipeRuntimeFn } from '../styled-system/types'
export const button: RecipeRuntimeFn<{ visual?: 'solid' | 'outline' }> = cva({ … })
SlotRecipeRuntimeFn and StyledComponent<Tag, Props> do the same for sva and styled.
Anti-patterns
- A theme that's one flat pile of primitives. If call sites all reference
gray.600, a rebrand becomes a find-and-replace across the app. That's what the semantic layer prevents. - Component names in global tokens.
colors.buttonPrimarymeans every future component invents its own. Name the role, not the consumer. - Redefining scales per component. A recipe whose
basere-declares spacing values is drifting from the theme. - Page layout inside a recipe. Recipes are for components. Page composition is
css()and patterns. - Dark mode branched at call sites. One
_darkin a semantic token beats fifty in components. - Slots added defensively. Slots you don't style are API you have to keep.
After changing the config
Run panda codegen (or panda build). Tokens, recipes, and their types don't exist until it does. Then read the CSS
diff. A token rename that silently drops a value shows up as CSS that stopped emitting.
It's working if
- A rebrand touches the config, not the call sites.
_darkappears intheme.semanticTokensand almost nowhere else.- Switching the
darkclass ordata-panda-themechanges the page, and no component reads either one. - Every theme in
themesis either listed instaticCss.themesor loaded throughgetTheme, so none emits nothing. - Grepping a primitive like
gray.600across components returns close to nothing. - Every slot in a recipe's
slotsarray has styles under it. - No token name contains a component name.
panda codegenruns clean, and the emitted CSS for a recipe matches the variants you meant to ship.
See also
- Writing styles against the system you designed: call the Skill tool with "panda-css".
- v1 to v2, including the
createStyleContextsplit: call the Skill tool with "panda-css-migrate".