Skip to content

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

JSX Usage

Tracking JSX

How Panda connects a JSX tag to a config recipe, and what breaks that connection.

Config recipes emit CSS for <Button size="lg" /> only if Panda can treat that tag as the recipe. The match is the component name, not the import. A local function named Button is enough, even if it never imported styled-system/recipes.

cva and sva do not need this. Defining them already emits every variant, so a missed JSX tag does not leave you without CSS.

Naming guide

A config recipe has three names. Mixing them up is the usual reason tracking fails.

theme: {
  recipes: {
    button: defineRecipe({
      className: 'btn', // .btn, .btn--size_sm
      jsx: ['Button'] // <Button>
    })
  }
}
import { button } from '../styled-system/recipes' // theme key
 
button({ size: 'sm' }) // btn btn--size_sm
 
<Button size="lg" /> // still Button, not Btn

Default tags

theme.recipes.button      →  Button
theme.slotRecipes.card    →  Card, Card.Root, CardRoot

Those names come from the theme key, not from className. The exported compound name still has to match. Card works. Panel does not, unless you set jsx.

const { withProvider, withContext } = createSlotRecipeContext(card)
 
// tracked
export const Card = {
  Root: withProvider('div', 'root'),
  Header: withContext('header', 'header')
}
 
// not tracked
export const Panel = {
  Root: withProvider('div', 'root'),
  Header: withContext('header', 'header')
}
<Card.Root size="lg">
  <Card.Header />
</Card.Root>

size is on the root. Header is a part.

jsx

Use jsx when the export name is not the default tag.

export const buttonRecipe = defineRecipe({
  className: 'button',
  jsx: ['Button', 'LinkButton', /Btn$/]
})

Strings are exact tags. A regex matches the full tag, including Button.Root.

⚠️

jsx replaces the default root name. It does not add to it. If you set jsx: ['LinkButton'] and omit Button, <Button> is no longer tracked.

On a slot recipe, jsx replaces the root name only. Slot compounds stay {ThemeKey}.{Slot} and {ThemeKey}{Slot}.

export const cardRecipe = defineSlotRecipe({
  className: 'card',
  slots: ['root', 'header'],
  jsx: ['Panel']
  // tracked:     Panel, Card.Root, Card.Header
  // not tracked: Card, Panel.Root, Panel.Header
})

If you export Panel = { Root, Header }, list the tags that carry variant props (Panel.Root), plus Panel if you render it. Or use a regex:

jsx: ['Panel', 'Panel.Root']
// or
jsx: [/^Panel/]

Keep Card / Card.Root in that list if those tags still appear in the app.

Skip tracking with staticCss

If you'd rather not keep jsx in sync with your component names, pregenerate the recipe's variants with staticCss. Panda then emits every variant whatever the tag is called, so a renamed export or a wrapper never leaves you without CSS. That's how cva and sva already work.

export const cardRecipe = defineSlotRecipe({
  className: 'card',
  slots: ['root', 'header'],
  variants: {
    size: {
      sm: { root: { padding: '2' } },
      lg: { root: { padding: '6' } }
    }
  },
  staticCss: ['*'] // every variant, whatever the tag is called
})

Or list the recipes in your config:

panda.config.ts

export default defineConfig({
  staticCss: {
    recipes: {
      button: ['*'],
      card: ['*']
    }
  }
})

The trade-off is size: you ship every variant, used or not. That's cheap for design-system recipes with a handful of variants. Avoid recipes: '*' across a large app. See Static CSS.

False matches

A local <Button> that is not this recipe can still be treated as one. The match is not import-gated.

A headless prop can also share a name with a CSS property (Select.Content position). You cannot exclude that tag in the current engine. If it causes a real problem, open an issue (opens in a new tab).

Renamed props (size → buttonSize) and a wrapper named Random are a different miss. See Extraction rules.

See also

Edit this page on GitHubView as markdown
Last updated on