🧩 Design System4. Recipes & Variants02 Panda Recipe Deep Dive — cva, defineRecipe, atomic vs config recipe

02 Panda Recipe Deep Dive — cva, defineRecipe, atomic vs config recipe

이 문서가 답하는 질문: Panda CSS는 같은 variant 정보를 왜 두 가지 API(cva() + defineRecipe())로 노출하는가. 그리고 빌드 타임에 정확히 어떤 일이 일어나서 zero-runtime이 가능한가. 한 줄 답 (Pyramid Top): Panda recipe는 **“variant 객체를 빌드 타임에 정적 분석해 CSS 한 벌과 className 매핑 테이블로 추출”**하는 시스템이다. cva()는 컴포넌트 파일 안에 같이 두는 atomic recipe, defineRecipe()panda.config.ts에 등록해 디자인 시스템 패키지로 공개하는 config recipe다.


Why — 왜 Panda는 recipe를 두 종류로 나눴는가

Stitches(2020)는 한 가지 패턴만 있었다 — styled('button', { variants }). 그러나 실무에선 두 가지 사용 시나리오가 명확히 갈렸다:

시나리오누가 정의누가 소비어디 사는가
앱 내부 한정 컴포넌트앱 개발자같은 앱components/Button.tsx
디자인 시스템 공개 컴포넌트디자인 시스템 팀여러 앱@org/design-system 패키지

전자는 지역적이라 코드 옆에 두는 게 편하고, 후자는 중앙집권적이라 한 곳에 등록되어 codegen으로 타입과 docs를 뽑아내야 한다. Panda는 이 둘을 각각 atomic recipe(cva())와 config recipe(defineRecipe())로 분리했다.


How — atomic recipe (cva())의 실제 코드와 동작

1단계: 코드 작성

// app/components/Button.tsx
import { cva } from 'styled-system/css'
 
export const button = cva({
  base: {
    display: 'inline-flex',
    alignItems: 'center',
    justifyContent: 'center',
    borderRadius: 'md',
    fontWeight: 'medium',
    cursor: 'pointer',
    transition: 'background 0.15s ease',
    _disabled: { opacity: 0.5, cursor: 'not-allowed' },
  },
  variants: {
    size: {
      sm: { h: '8', px: '3', fontSize: 'sm' },
      md: { h: '10', px: '4', fontSize: 'md' },
      lg: { h: '12', px: '6', fontSize: 'lg' },
    },
    variant: {
      primary: {
        bg: 'primary',
        color: 'white',
        _hover: { bg: 'primary.600' },
      },
      ghost: {
        bg: 'transparent',
        color: 'primary',
        _hover: { bg: 'primary.50' },
      },
      danger: {
        bg: 'red.500',
        color: 'white',
        _hover: { bg: 'red.600' },
      },
    },
  },
  compoundVariants: [
    {
      variant: 'primary',
      size: 'lg',
      css: { fontWeight: 'bold', letterSpacing: 'wide' },
    },
  ],
  defaultVariants: { size: 'md', variant: 'primary' },
})
 
export function Button({
  size,
  variant,
  ...props
}: React.ButtonHTMLAttributes<HTMLButtonElement> & {
  size?: 'sm' | 'md' | 'lg'
  variant?: 'primary' | 'ghost' | 'danger'
}) {
  return <button className={button({ size, variant })} {...props} />
}

2단계: 빌드 타임 — Panda가 하는 일

Panda CLI(panda codegen + Vite/Next 플러그인)는 모든 소스 파일을 정적 분석하여 다음을 추출한다:

  1. 사용된 base 스타일 → CSS 규칙

    .d_inline-flex { display: inline-flex }
    .ai_center { align-items: center }
    .bdr_md { border-radius: var(--radii-md) }
    /* ... */
  2. 사용된 variant 조합 → CSS 규칙

    .h_8 { height: var(--sizes-8) }
    .h_10 { height: var(--sizes-10) }
    .bg_primary { background: var(--colors-primary) }
    .bg_primary:hover { background: var(--colors-primary-600) }
  3. compoundVariants → 별도 클래스

    /* "primary + lg" 조합 전용 */
    .fw_bold { font-weight: 700 }
    .ls_wide { letter-spacing: 0.025em }
  4. button({ size: 'lg', variant: 'primary' }) → className 문자열 호출 결과는 "d_inline-flex ai_center bdr_md h_12 px_6 fs_lg bg_primary fw_bold ls_wide ..." 같은 정적 조립. 런타임에 어떤 분기도 없다.


How — config recipe (defineRecipe())의 차이점

1단계: panda.config.ts에 정의

// panda.config.ts
import { defineConfig, defineRecipe } from '@pandacss/dev'
 
const buttonRecipe = defineRecipe({
  className: 'button', // 생성될 CSS 클래스의 prefix
  description: '기본 버튼',
  base: {
    display: 'inline-flex',
    alignItems: 'center',
    borderRadius: 'md',
  },
  variants: {
    size: {
      sm: { h: '8', px: '3' },
      md: { h: '10', px: '4' },
      lg: { h: '12', px: '6' },
    },
    variant: {
      primary: { bg: 'primary', color: 'white' },
      ghost: { bg: 'transparent', color: 'primary' },
    },
  },
  compoundVariants: [
    { variant: 'primary', size: 'lg', css: { fontWeight: 'bold' } },
  ],
  defaultVariants: { size: 'md', variant: 'primary' },
})
 
export default defineConfig({
  theme: {
    recipes: {
      button: buttonRecipe,
    },
  },
})

2단계: codegen으로 import 경로 생성

panda codegen을 돌리면:

// styled-system/recipes/button.d.ts (자동 생성)
import type { RecipeVariantProps } from '../types'
 
export type ButtonVariantProps = {
  size?: 'sm' | 'md' | 'lg'
  variant?: 'primary' | 'ghost'
}
 
export declare const button: (props?: ButtonVariantProps) => string

3단계: 컴포넌트에서 소비

import { button } from 'styled-system/recipes'
 
export function Button({ size, variant, ...props }) {
  return <button className={button({ size, variant })} {...props} />
}

config recipe의 강점

강점의미
단일 출처모든 recipe가 panda.config.ts에 → 디자인 시스템 전체를 한 페이지로 조감
CSS 클래스 이름.button 으로 시작하는 읽기 좋은 클래스. atomic은 .d_inline-flex 같은 압축형
docs 자동화description 필드 + codegen으로 Storybook/Nextra docs 생성 가능
jsxdefineRecipejsx: ['Button']을 두면 자동으로 <Button> 컴포넌트 stub 생성

What — atomic vs config recipe 비교

측면atomic recipe (cva())config recipe (defineRecipe())
위치컴포넌트 파일 옆panda.config.tstheme.recipes
importfrom 'styled-system/css'from 'styled-system/recipes'
CSS 클래스명atomic (.d_flex .bg_primary)semantic (.button .button--primary)
타입 추출인라인으로 직접 작성codegen이 자동 생성
docs 통합없음description 필드 + 자동 docs
재사용 단위같은 앱/패키지 안전 조직 공유
권장 상황일회성·앱 한정 컴포넌트DS 패키지의 공개 컴포넌트

What — slot recipe (예고편)

config recipe에는 한 단계 더 강력한 변종이 있다 — slot recipe. 한 컴포넌트가 여러 부분(root, icon, label)로 구성될 때 각 부분에 variant를 다르게 적용한다.

import { defineSlotRecipe } from '@pandacss/dev'
 
const card = defineSlotRecipe({
  className: 'card',
  slots: ['root', 'header', 'body', 'footer'],
  base: {
    root: { borderRadius: 'lg', overflow: 'hidden' },
    header: { p: '4', borderBottomWidth: '1px' },
    body: { p: '4' },
    footer: { p: '4', borderTopWidth: '1px' },
  },
  variants: {
    tone: {
      default: { root: { bg: 'white' } },
      emphasis: {
        root: { bg: 'primary.50' },
        header: { bg: 'primary.100' },
      },
    },
  },
})

slot recipe는 05-composition 챕터에서 본격적으로 다룬다.


What-if — Panda recipe의 함정

  • 함정 1: cva()조건문 안에서 호출 → 정적 분석 실패. 항상 모듈 top-level에 두기

    // ❌ 안 잡힘
    if (isV2) {
      const styles = cva({ ... })
    }
    // ✅ top-level에 분리
    const v1 = cva({ ... })
    const v2 = cva({ ... })
  • 함정 2: variant 값을 string concat으로 — Tailwind와 같은 함정

    // ❌ Panda가 'primary' / 'ghost' 둘 다 봐야 함
    button({ variant: `${isPrimary ? 'primary' : 'ghost'}` })
    // ✅ 명시적 값
    button({ variant: isPrimary ? 'primary' : 'ghost' })
  • 함정 3: compoundVariants의 매칭 순서 — 배열의 가 이긴다. 두 규칙이 같은 조합을 가리키면 의도와 다른 게 적용됨

  • 함정 4: token alias가 깨져도 빌드 에러가 안 남는 경우 — Panda는 모르는 토큰을 빈 값으로 두고 경고만 띄움. CI에 panda lint 추가 필요

  • 함정 5: HMR 캐시 — recipe 정의를 바꿔도 변화가 안 보이면 .panda/ 캐시 삭제


Insight — 왜 Panda는 “zero-runtime”을 약속할 수 있는가

핵심은 variants 객체가 직렬화 가능한 데이터 라는 점이다. 함수도, 동적 클로저도 아니다. 그래서 Panda의 정적 분석기(esbuild + AST walker)가 모든 호출처를 컴파일 타임에 평가할 수 있다.

// 이 호출은 빌드 타임에 평가됨
button({ size: 'lg', variant: 'primary' })
// → 정확한 className 문자열로 컴파일 결과에 박힘

styled-components(2017) 시대에는 템플릿 리터럴 안에 임의 JS가 들어가서 정적 분석이 불가능했다. Panda는 그 자유도를 의도적으로 포기하고, 대신 런타임 비용 0타입 안정성을 얻었다.

이게 Tailwind와 Panda의 동형 관계다 — 둘 다 “JS는 정적 분석이 가능한 형태로 작성하라, 그러면 우리가 CSS로 추출해주겠다”는 같은 약속을 한다. 다른 것은 입력 문법뿐(className="..." vs cva({...})).

흥미로운 이야기: Panda CSS의 메인테이너 segunadebayo는 Chakra UI의 창시자다. Chakra UI는 v2까지 런타임 CSS-in-JS였고, v3로 가면서 zero-runtime이 필요해지자 Panda를 만들었다. 즉 Panda는 Chakra의 다음 세대를 위한 컴파일러로 태어났고, Chakra v3는 Panda recipe를 표준으로 채택했다.


요약

  • Panda recipe는 두 종류 — atomic(cva())과 config(defineRecipe())
  • atomic은 컴포넌트 옆에, config는 panda.config.ts
  • 빌드 타임 정적 분석으로 zero-runtime — 런타임 분기 비용이 없다
  • variants 객체가 데이터로 직렬화 가능하다는 제약이 zero-runtime을 가능하게 한다
  • Chakra v3 → Panda 채택으로 사실상 React 생태계의 recipe 표준 후보