Files
gh-shaunrfox-okshaun-claude…/skills/panda-recipe-patterns.md
2025-11-30 08:56:16 +08:00

20 KiB

name, description
name description
panda-recipe-patterns Create and organize recipes (regular + slot recipes), compound variants, and understand when to use recipes vs patterns vs inline CSS

Panda CSS Recipe Patterns

When to Use This Skill

Use this skill when:

  • Creating reusable component styles with variants
  • Building multi-part component styling (checkboxes, tooltips, menus)
  • Defining compound variants (multiple conditions)
  • Organizing component style libraries
  • Deciding between recipes, patterns, and inline CSS

For implementing these recipes in React components, use the panda-component-impl skill.

When to Use What

Use Recipes When:

  • Component has multiple style variants (e.g., button: primary, secondary, outline)
  • Component has size variants (small, medium, large)
  • Styles are reused across multiple instances
  • Need compound variants (combining multiple variant conditions)
  • Want to auto-apply styles to JSX components
  • Building a design system with consistent component APIs

Use Patterns When:

  • Need computed/transformed styles (e.g., icon sizing that sets width=height)
  • Creating reusable layout primitives (stack, grid, container)
  • Want a props-based API for common styling tasks
  • Need to enforce constraints (e.g., size must be a valid token)

Use Inline CSS When:

  • One-off styles specific to a single usage
  • Dynamic values from props or state
  • Component-specific overrides of recipe defaults
  • Rapid prototyping before extracting to recipe

Example Decision Tree:

Button component with variants? → Recipe
Icon with size prop? → Pattern
Unique spacing on one div? → Inline CSS
Reusable card layout? → Recipe

Regular Recipes (Single-Part Components)

Basic Recipe Structure

Create: src/recipes/button.ts

import { defineRecipe } from '@pandacss/dev';

const buttonBase = {
  position: 'relative',
  appearance: 'none',
  minWidth: '0',
  transitionDuration: 'fast',
  transitionProperty: 'background, border-color, color, box-shadow',
  transitionTimingFunction: 'default',
  userSelect: 'none',
  verticalAlign: 'middle',
  display: 'flex',
  alignItems: 'center',
  gap: '4',
  fontFamily: 'body',
  fontSize: '16',
  fontWeight: 'medium',
  lineHeight: 'default',
  borderWidth: '1',
  borderStyle: 'solid',
  borderColor: 'transparent',
  borderRadius: '4',
  outlineWidth: '2',
  outlineStyle: 'solid',
  outlineColor: 'transparent',
  outlineOffset: '1',
  textDecoration: 'none',
  whiteSpace: 'nowrap',
  cursor: 'pointer',
  _disabled: {
    opacity: 0.4,
    cursor: 'not-allowed',
  },
  _focusVisible: {
    outlineColor: { base: 'slate.80', _dark: 'slate.5' },
  },
  '& svg': {
    fill: 'current',
  },
};

const buttonVariants = {
  variant: {
    primary: {
      bg: { base: 'slate.90', _dark: 'slate.5' },
      color: { base: 'slate.0', _dark: 'slate.90' },
      _hover: {
        bg: { base: 'slate.70', _dark: 'slate.10' },
      },
      _active: {
        bg: { base: 'slate.100', _dark: 'slate.20' },
      },
      _disabled: {
        _hover: {
          bg: { base: 'slate.90', _dark: 'slate.5' },
        },
      },
      _selected: {
        bg: { base: 'slate.5', _dark: 'slate.90' },
        color: { base: 'slate.90', _dark: 'slate.0' },
      },
    },
    standard: {
      bg: { base: 'slate.5', _dark: 'slate.70' },
      color: { base: 'slate.90', _dark: 'slate.0' },
      _hover: {
        bg: { base: 'slate.10', _dark: 'slate.60' },
      },
      _active: {
        bg: { base: 'slate.20', _dark: 'slate.80' },
      },
      _disabled: {
        _hover: {
          bg: { base: 'slate.5', _dark: 'slate.70' },
        },
      },
      _selected: {
        bg: { base: 'slate.90', _dark: 'slate.5' },
        color: { base: 'slate.0', _dark: 'slate.90' },
      },
    },
    hollow: {
      bg: 'transparent',
      borderColor: { base: 'slate.30', _dark: 'slate.60' },
      color: { base: 'slate.90', _dark: 'slate.0' },
      _hover: {
        bg: { base: 'slate.10', _dark: 'slate.60' },
        borderColor: { base: 'slate.10', _dark: 'slate.60' },
      },
      _active: {
        bg: { base: 'slate.20', _dark: 'slate.80' },
        borderColor: { base: 'slate.20', _dark: 'slate.80' },
      },
      _disabled: {
        _hover: {
          bg: 'transparent',
        },
      },
      _selected: {
        bg: { base: 'slate.90', _dark: 'slate.5' },
        color: { base: 'slate.0', _dark: 'slate.90' },
        borderColor: 'transparent',
      },
    },
    ghost: {
      bg: 'transparent',
      color: { base: 'slate.90', _dark: 'slate.0' },
      _hover: {
        bg: { base: 'slate.10', _dark: 'slate.60' },
      },
      _active: {
        bg: { base: 'slate.20', _dark: 'slate.70' },
      },
      _disabled: {
        _hover: {
          bg: 'transparent',
        },
      },
      _selected: {
        bg: { base: 'slate.90', _dark: 'slate.5' },
        color: { base: 'slate.0', _dark: 'slate.90' },
      },
    },
    cta: {
      bg: { base: 'gold.20', _dark: 'gold.30' },
      color: 'slate.90',
      _hover: {
        bg: { base: 'gold.10', _dark: 'gold.20' },
      },
      _active: {
        bg: { base: 'gold.30', _dark: 'gold.40' },
      },
      _disabled: {
        _hover: {
          bg: { base: 'gold.20', _dark: 'gold.30' },
        },
      },
    },
    danger: {
      bg: 'red.50',
      color: 'slate.0',
      _hover: {
        bg: 'red.40',
      },
      _active: {
        bg: 'red.60',
      },
      _disabled: {
        _hover: {
          bg: 'red.50',
        },
      },
    },
  },
};

export const buttonRecipe = defineRecipe({
  className: 'button',
  jsx: ['Button'],
  base: buttonBase,
  variants: {
    ...buttonVariants,
    size: {
      medium: {
        fontSize: '16',
        py: '3',
        px: '10',
      },
      large: {
        fontSize: '16',
        py: '7',
        px: '12',
      },
      small: {
        fontSize: '14',
        py: '0',
        px: '8',
        '& svg': {
          mt: '-1',
          mb: '-1',
        },
      },
    },
  },
  defaultVariants: {
    variant: 'standard',
    size: 'medium',
  },
});

export const iconButtonRecipe = defineRecipe({
  className: 'icon-button',
  jsx: ['IconButton'],
  base: buttonBase,
  variants: {
    ...buttonVariants,
    size: {
      medium: {
        fontSize: '16',
        p: '3',
      },
      large: {
        fontSize: '16',
        p: '7',
      },
      small: {
        fontSize: '14',
        p: '0',
        '& svg': {
          mt: '-1',
          mb: '-1',
        },
      },
    },
  },
  defaultVariants: {
    variant: 'standard',
    size: 'medium',
  },
});

Extract Base Styles for DRY Code

Pattern: Share base styles between related recipes:

// Shared base for button and iconButton
const buttonBase = {
  appearance: 'none',
  display: 'inline-flex',
  alignItems: 'center',
  justifyContent: 'center',
  cursor: 'pointer',
  transitionDuration: 'fast',
  _disabled: { opacity: 0.4, cursor: 'not-allowed' },
  _focusVisible: { outlineWidth: '2', outlineColor: 'blue.50' }
}

// Shared variant styles
const buttonVariants = {
  variant: {
    primary: { /* ... */ },
    secondary: { /* ... */ }
  }
}

// Button recipe
export const buttonRecipe = defineRecipe({
  className: 'button',
  jsx: ['Button'],
  base: buttonBase,
  variants: {
    ...buttonVariants,
    size: { /* button sizes */ }
  },
  defaultVariants: { variant: 'primary', size: 'medium' }
})

// IconButton recipe (reuses base and variants)
export const iconButtonRecipe = defineRecipe({
  className: 'iconButton',
  jsx: ['IconButton'],
  base: buttonBase,
  variants: {
    ...buttonVariants,
    size: {
      small: { width: '32', height: '32' },
      medium: { width: '40', height: '40' },
      large: { width: '48', height: '48' }
    }
  },
  defaultVariants: { variant: 'primary', size: 'medium' }
})

Why: Keeps related components visually consistent, reduces duplication.

Dynamic Variants from Tokens

Pattern: Generate variants from design tokens:

import { tokens } from '../styles/tokens'

// Get fontSize tokens
const fontSizeTokens = tokens.fontSizes

type FontSizeKey = keyof typeof fontSizeTokens

// Generate fontSize variants dynamically
const fontSizes = (Object.keys(fontSizeTokens) as FontSizeKey[]).reduce(
  (accumulator, currentKey) => {
    accumulator[currentKey] = { fontSize: currentKey }
    return accumulator
  },
  {} as Record<FontSizeKey, Record<'fontSize', string>>
)

export const textRecipe = defineRecipe({
  className: 'text',
  jsx: ['Text'],
  variants: {
    size: fontSizes  // All token sizes available as variants
  }
})

Why: Automatically sync recipe variants with token changes.

Slot Recipes (Multi-Part Components)

Use slot recipes for components with multiple styled parts (checkbox + label, tooltip + arrow, menu + items).

Basic Slot Recipe Structure

Create: src/recipes/checkbox.ts

import { defineSlotRecipe } from '@pandacss/dev'

export const checkBoxRecipe = defineSlotRecipe({
  className: 'checkbox',
  description: 'Checkbox component with label',
  jsx: ['CheckBox'],

  // Define named slots for component parts
  slots: ['container', 'input', 'indicator', 'label'],

  // Base styles for each slot
  base: {
    container: {
      display: 'inline-flex',
      alignItems: 'center',
      gap: '8',
      cursor: 'pointer',
      position: 'relative'
    },

    input: {
      // Visually hidden but accessible
      position: 'absolute',
      opacity: 0,
      width: '1px',
      height: '1px',

      // Show different indicator icons based on state
      _checked: {
        "& ~ [data-part='indicator'][data-state='unchecked']": {
          display: 'none'
        },
        "& ~ [data-part='indicator'][data-state='checked']": {
          display: 'inline-flex'
        }
      },

      _indeterminate: {
        "& ~ [data-part='indicator'][data-state='indeterminate']": {
          display: 'inline-flex'
        }
      },

      _disabled: {
        "& ~ [data-part='indicator']": {
          opacity: 0.4,
          cursor: 'not-allowed'
        }
      }
    },

    indicator: {
      display: 'inline-flex',
      alignItems: 'center',
      justifyContent: 'center',
      flexShrink: 0,
      width: '20',
      height: '20',
      borderWidth: '1',
      borderRadius: '4',
      borderColor: { base: 'slate.40', _dark: 'slate.60' },
      bg: { base: 'white', _dark: 'slate.90' },
      color: { base: 'blue.50', _dark: 'blue.40' }
    },

    label: {
      fontSize: 'md',
      color: { base: 'slate.90', _dark: 'slate.10' },
      userSelect: 'none'
    }
  },

  // Variants apply to specific slots
  variants: {
    size: {
      small: {
        indicator: { width: '16', height: '16' },
        label: { fontSize: 'sm' }
      },
      medium: {
        indicator: { width: '20', height: '20' },
        label: { fontSize: 'md' }
      },
      large: {
        indicator: { width: '24', height: '24' },
        label: { fontSize: 'lg' }
      }
    }
  },

  defaultVariants: {
    size: 'medium'
  }
})

Complex State Handling

Pattern: Use sibling selectors for state-based styling:

base: {
  input: {
    // When checked, show checked icon, hide unchecked icon
    _checked: {
      "& ~ [data-part='indicator']": {
        bg: { base: 'blue.50', _dark: 'blue.40' },
        borderColor: { base: 'blue.50', _dark: 'blue.40' },
        color: 'white'
      }
    },

    // When focused, add focus ring to indicator
    _focusVisible: {
      "& ~ [data-part='indicator']": {
        outlineWidth: '2',
        outlineOffset: '1',
        outlineColor: { base: 'blue.50', _dark: 'blue.40' }
      }
    },

    // Error state
    _invalid: {
      "& ~ [data-part='indicator']": {
        borderColor: { base: 'red.50', _dark: 'red.40' }
      },
      "& ~ [data-part='label']": {
        color: { base: 'red.50', _dark: 'red.40' }
      }
    }
  }
}

Advanced Slot Recipe: Tooltip

Create: src/recipes/tooltip.ts

import { defineSlotRecipe } from '@pandacss/dev'

export const tooltipRecipe = defineSlotRecipe({
  className: 'tooltip',
  jsx: ['Tooltip'],
  slots: ['trigger', 'content', 'arrow'],

  base: {
    trigger: {
      cursor: 'pointer'
    },

    content: {
      position: 'absolute',
      zIndex: 50,
      px: '12',
      py: '8',
      fontSize: 'sm',
      borderRadius: '6',
      bg: { base: 'slate.90', _dark: 'slate.10' },
      color: { base: 'white', _dark: 'slate.90' },
      boxShadow: 'lg',
      maxWidth: '320',

      // Fade in/out animation
      opacity: 0,
      transitionProperty: 'opacity',
      transitionDuration: 'fast',
      _open: {
        opacity: 1
      }
    },

    arrow: {
      position: 'absolute',
      width: '8',
      height: '8',
      bg: { base: 'slate.90', _dark: 'slate.10' },
      transform: 'rotate(45deg)'
    }
  },

  variants: {
    position: {
      top: {
        content: { bottom: 'full', left: '50%', transform: 'translateX(-50%)' },
        arrow: { bottom: '-4', left: '50%', transform: 'translateX(-50%) rotate(45deg)' }
      },
      'top-start': {
        content: { bottom: 'full', left: '0' },
        arrow: { bottom: '-4', left: '12' }
      },
      'top-end': {
        content: { bottom: 'full', right: '0' },
        arrow: { bottom: '-4', right: '12' }
      },
      bottom: {
        content: { top: 'full', left: '50%', transform: 'translateX(-50%)' },
        arrow: { top: '-4', left: '50%', transform: 'translateX(-50%) rotate(45deg)' }
      },
      left: {
        content: { right: 'full', top: '50%', transform: 'translateY(-50%)' },
        arrow: { right: '-4', top: '50%', transform: 'translateY(-50%) rotate(45deg)' }
      },
      right: {
        content: { left: 'full', top: '50%', transform: 'translateY(-50%)' },
        arrow: { left: '-4', top: '50%', transform: 'translateY(-50%) rotate(45deg)' }
      }
    },

    // Option to hide arrow
    caret: {
      true: {},
      false: {
        arrow: { display: 'none' }
      }
    }
  },

  // Compound variants: combine multiple variant conditions
  compoundVariants: [
    {
      position: ['top', 'top-start', 'top-end'],
      caret: true,
      css: {
        content: { mb: '12' }  // Add margin when arrow is shown on top
      }
    },
    {
      position: ['bottom', 'bottom-start', 'bottom-end'],
      caret: true,
      css: {
        content: { mt: '12' }
      }
    }
  ],

  defaultVariants: {
    position: 'top',
    caret: true
  }
})

Compound Variants

Use compound variants when combining multiple variant values requires unique styling.

Pattern: Conditional styling based on variant combinations:

export const buttonRecipe = defineRecipe({
  variants: {
    variant: {
      primary: { /* ... */ },
      outline: { /* ... */ }
    },
    size: {
      small: { /* ... */ },
      large: { /* ... */ }
    },
    loading: {
      true: { cursor: 'wait' }
    }
  },

  // Special styles when specific variants combine
  compoundVariants: [
    {
      variant: 'primary',
      loading: true,
      css: {
        bg: { base: 'blue.40', _dark: 'blue.30' },  // Lighter when loading
        _hover: { bg: { base: 'blue.40', _dark: 'blue.30' } }  // Disable hover
      }
    },
    {
      variant: 'outline',
      size: 'small',
      css: {
        borderWidth: '1',  // Thinner border for small outline
        fontWeight: 'medium'
      }
    }
  ]
})

Recipe Organization

File Structure

src/
  recipes/
    index.ts              # Export all recipes
    button.ts            # Regular recipe
    input.ts             # Regular recipe
    checkbox.ts          # Slot recipe
    radio.ts             # Slot recipe
    tooltip.ts           # Slot recipe
    menu.ts              # Slot recipe

Export Pattern

src/recipes/index.ts:

// Regular recipes
export { buttonRecipe } from './button'
export { iconButtonRecipe } from './button'
export { inputRecipe } from './input'
export { textRecipe } from './text'

// Slot recipes
export { checkBoxRecipe } from './checkbox'
export { radioRecipe } from './radio'
export { tooltipRecipe } from './tooltip'
export { menuRecipe } from './menu'

Register in Config

panda.config.ts:

import { defineConfig } from '@pandacss/dev'
import * as allRecipes from './src/recipes'

// Separate regular and slot recipes
const {
  checkBoxRecipe,
  radioRecipe,
  tooltipRecipe,
  menuRecipe,
  ...regularRecipes
} = allRecipes

// Transform keys: remove 'Recipe' suffix
const recipes = Object.fromEntries(
  Object.entries(regularRecipes).map(([key, value]) => [
    key.replace(/Recipe$/, ''),  // buttonRecipe → button
    value
  ])
)

const slotRecipes = {
  checkbox: checkBoxRecipe,
  radio: radioRecipe,
  tooltip: tooltipRecipe,
  menu: menuRecipe
}

export default defineConfig({
  theme: {
    extend: {
      recipes,
      slotRecipes
    }
  }
})

Why: Clean separation, automatic recipe registration.

Responsive Recipes

Pattern: Use object syntax for responsive variants:

export const cardRecipe = defineRecipe({
  base: {
    p: { base: '16', md: '20', lg: '24' },  // Responsive padding
    borderRadius: { base: '6', md: '8' },
    fontSize: { base: 'sm', md: 'md' }
  }
})

Pattern: Container queries for component-level responsive:

export const menuRecipe = defineSlotRecipe({
  base: {
    container: {
      // Change layout based on container size (not viewport)
      width: { base: 'full', '@container(min-width: 768px)': '260' },
      position: { base: 'fixed', '@container(min-width: 768px)': 'relative' }
    }
  }
})

Best Practices Checklist

Create TodoWrite items when creating recipes:

  • Extract shared base styles for related components
  • Use semantic tokens (not raw colors) in recipes
  • Define sensible defaultVariants
  • Include common state styles (_hover, _focus, _disabled, _active)
  • Add _focusVisible for accessibility
  • Use slot recipes for multi-part components
  • Document recipe purpose in description field
  • Use compound variants for complex combinations
  • Test all variant combinations
  • Validate recipes work in light AND dark themes

Common Pitfalls

Avoid: Hard-coded Values

// BAD: Hard-coded hex colors, px values
variants: {
  primary: {
    bg: '#3B82F6',
    padding: '12px'
  }
}

// GOOD: Use design tokens
variants: {
  primary: {
    bg: { base: 'blue.50', _dark: 'blue.40' },
    padding: '12'
  }
}

Avoid: Missing Default Variants

// BAD: No defaults, components render unstyled
variants: {
  size: { small: {...}, large: {...} }
}

// GOOD: Always provide defaults
variants: {
  size: { small: {...}, medium: {...}, large: {...} }
},
defaultVariants: {
  size: 'medium'
}

Avoid: Over-nesting in Slot Recipes

// BAD: Deep nesting, hard to maintain
base: {
  container: {
    '& > div > span > button': { /* ... */ }
  }
}

// GOOD: Use slots for structure
slots: ['container', 'wrapper', 'label', 'button'],
base: {
  container: { /* ... */ },
  button: { /* ... */ }
}

Avoid: Duplicate Logic Across Recipes

// BAD: Copy-paste same styles
const button = { _disabled: { opacity: 0.4 } }
const input = { _disabled: { opacity: 0.4 } }

// GOOD: Extract to shared constant
const disabledStyles = { opacity: 0.4, cursor: 'not-allowed' }

const button = { _disabled: disabledStyles }
const input = { _disabled: disabledStyles }

Accessing Official Panda CSS Docs

For recipe-specific documentation:

  1. Resolve library ID: mcp__MCP_DOCKER__resolve-library-id with libraryName: "panda-css"
  2. Fetch docs: mcp__MCP_DOCKER__get-library-docs with topic: "recipes" or topic: "slot-recipes"

Next Steps

After creating recipes:

  1. Implement in components: Use panda-component-impl skill for React integration
  2. Test variants: Verify all combinations work visually
  3. Document in Storybook: Create stories showing all variants

Reference Files from Best Practices Repo

  • Regular recipe: src/recipes/button.ts
  • Slot recipe: src/recipes/checkbox.ts, src/recipes/tooltip.ts, src/recipes/menu.ts
  • Shared base: src/recipes/button.ts (buttonBase constant)
  • Dynamic variants: src/recipes/text.ts (fontSize generation)
  • Config registration: panda.config.ts, cetec-preset.ts