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

styling
thinking in panda

Thinking in Panda

Which Panda API to reach for next, from css() to recipes, slots, and config.

Panda has one mental model, and everything below is that model at different sizes. Four ideas carry it:

  • Styles live next to your markup. You write them as objects in the same file as the component, not in a separate stylesheet to keep in sync.
  • Tokens are the vocabulary. Name intent once (blue.500, md, semibold) and every style refers to the same names.
  • Atomic CSS that scales. Each property and value becomes one shared class, so your CSS grows with the number of distinct styles, not the number of components.
  • Zero runtime. It all compiles to plain CSS at build time. Nothing computes styles in the browser.

The only question day to day is which API to reach for as a component grows. Start local. Promote only when the shape of the styles forces it. This walk builds a Button, then a Card, and climbs the ladder: csscvasva → config.

Want the case for build-time styles first? Read Welcome to Panda.

Step 1: Start with css()

Say you need a button, one look, no variants yet. Use css() to put the styles right on the element:

import { css } from '../styled-system/css'
 
export function Button({ children }: { children: React.ReactNode }) {
  return (
    <button
      className={css({
        display: 'inline-flex',
        alignItems: 'center',
        rounded: 'md',
        fontWeight: 'semibold',
        bg: 'blue.500',
        color: 'white',
        px: '4',
        py: '2',
        _hover: { bg: 'blue.600' },
        md: { px: '5' }
      })}
    >
      {children}
    </button>
  )
}

A few habits show up here and stay true for every later step.

Tokens name intent. blue.500, md, semibold come from your theme. Panda resolves them to CSS variables.

Conditions live in the object. _hover and md are keys next to the rest of the styles. No separate media file.

Keep styles static. Panda reads your source at build time, so literals, local constants, and ternaries are all fine. The moment a value only exists once the app is running, Panda can't see it. Map runtime choices to styles Panda can see:

// ❌ Panda can't see `shade` at build time
function Text({ shade }: { shade: number }) {
  return <p className={css({ color: `red.${shade}` })} />
}
 
// ✅ every class exists at build time
const byShade = {
  300: css({ color: 'red.300' }),
  500: css({ color: 'red.500' })
}
 
function Text({ shade }: { shade: keyof typeof byShade }) {
  return <p className={byShade[shade]} />
}
💡

byShade is really just a variant map without the name. Catch yourself hand-rolling one like this and take it as a sign: that's cva, coming up in Step 2. For values that are truly dynamic, a user-picked color, a computed length, skip the lookup map too: use CSS variables instead.

Each property and value becomes a shared atomic class. Write css({ color: 'white' }) in two different files, and Panda still only generates it once. Your CSS scales with how many distinct styles you use, not with how many components you have.

More on the object syntax: Writing styles.

Step 2: Promote to a recipe when variants appear

The button now needs size and visual. Duplicating css() calls or nesting ternaries isn't an effective approach:

// works, but every new variant makes this harder to read
function Button({ size, visual, children }) {
  return (
    <button
      className={css({
        rounded: 'md',
        fontWeight: 'semibold',
        px: size === 'sm' ? '3' : '5',
        py: size === 'sm' ? '1.5' : '3',
        bg: visual === 'solid' ? 'blue.500' : 'transparent',
        color: visual === 'solid' ? 'white' : 'blue.500',
        borderWidth: visual === 'outline' ? '1px' : undefined,
        borderColor: visual === 'outline' ? 'blue.500' : undefined
      })}
    >
      {children}
    </button>
  )
}

That's a variant matrix. Reach for cva:

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

Promote to a recipe when you have variants to name, not because the component file got long. One element, many looks: cva is enough.

💡

That "before" example still compiles, unlike Step 1's `red.${shade}`. A ternary's branches are both literal, so Panda can see them and generates CSS for both. A template literal's interpolation isn't a fixed set of options, so there's nothing for Panda to generate. Runtime choice between literals is fine. Runtime computation of a new one is not.

Full API: Recipes.

Step 3: Split into slots when markup has parts

Now let's build a Card. A Card has a root, title, and a body. One size prop should style all three together.

sva gives you one recipe and a class bag per part:

import { sva } from '../styled-system/css'
 
const card = sva({
  slots: ['root', 'title', 'body'],
  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' }
})
 
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>
  )
}

Use slots when one variant API owns multiple elements. Stay on cva when a single element is enough.

Full API: Slot recipes.

Step 4: Move to config when it's a system primitive

cva and sva are fine as long as the recipe stays colocated: it only matters inside this one component's file. Move the same base / variants / defaultVariants shape into defineRecipe or defineSlotRecipe once any of this gets true: other apps need to import the component, it ships inside a preset, or you want Panda to generate CSS only for the variants your code actually calls, not the full matrix.

Button becomes a config recipe:

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: {
    size: {
      sm: { px: '3', py: '1.5', fontSize: 'sm' },
      lg: { px: '5', py: '3', fontSize: 'md' }
    },
    visual: {
      solid: { bg: 'blue.500', color: 'white', _hover: { bg: 'blue.600' } },
      outline: { borderWidth: '1px', borderColor: 'blue.500', color: 'blue.500' }
    }
  },
  defaultVariants: {
    size: 'sm',
    visual: 'solid'
  }
})

Card becomes a config slot recipe:

card.recipe.ts

import { defineSlotRecipe } from '@pandacss/dev'
 
export const cardRecipe = defineSlotRecipe({
  className: 'card',
  slots: ['root', 'title', 'body'],
  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' }
})

Register them on the theme, then import the generated recipes from styled-system:

panda.config.ts

import { defineConfig } from '@pandacss/dev'
import { buttonRecipe } from './button.recipe'
import { cardRecipe } from './card.recipe'
 
export default defineConfig({
  // ...
  theme: {
    extend: {
      recipes: { button: buttonRecipe },
      slotRecipes: { card: cardRecipe }
    }
  }
})

The call signature doesn't change. Only the import path does, from a local file to styled-system:

import { button, card } from '../styled-system/recipes'
 
const classes = card({ size: 'lg' })
 
<button className={button({ size: 'lg', visual: 'outline' })}>Buy now</button>
<div className={classes.root}>
  <h2 className={classes.title}>Plan</h2>
</div>

Decision rule:

  • Stay on cva / sva for colocated, app-local components.
  • Move to defineRecipe / defineSlotRecipe for design-system components. You get leaner JIT CSS, and you can share the recipe through a preset or panda lib.

The full comparison table lives here: Should I use atomic or config recipes?. The same split applies to sva vs config slot recipes.

Step 5: Trust the system

A few things don't change as you move between css, cva, sva, and config: tokens, cascade layers, and types.

Tokens are the vocabulary. Name intent once: colors.text, spacing.4. Every css, recipe, and slot refers to the same names. See tokens.

Cascade layers keep overrides predictable. Panda emits into fixed layers, reset, base, tokens, recipes, utilities, and a utility always beats a recipe no matter what order you import things in. That's decided at the CSS layer, not in your code. Merge style objects yourself, though, and a different rule kicks in: the last value wins. See cascade layers and merging styles.

Types come from your config. Autocomplete offers your tokens and recipe variants. A typo is a type error while you type. Turn on strictTokens when you want raw values rejected too.

Where to go next

Know which step a style belongs on? You already think in Panda.