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 플러그인)는 모든 소스 파일을 정적 분석하여 다음을 추출한다:
-
사용된 base 스타일 → CSS 규칙
.d_inline-flex { display: inline-flex } .ai_center { align-items: center } .bdr_md { border-radius: var(--radii-md) } /* ... */ -
사용된 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) } -
compoundVariants → 별도 클래스
/* "primary + lg" 조합 전용 */ .fw_bold { font-weight: 700 } .ls_wide { letter-spacing: 0.025em } -
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) => string3단계: 컴포넌트에서 소비
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 생성 가능 |
jsx | defineRecipe에 jsx: ['Button']을 두면 자동으로 <Button> 컴포넌트 stub 생성 |
What — atomic vs config recipe 비교
| 측면 | atomic recipe (cva()) | config recipe (defineRecipe()) |
|---|---|---|
| 위치 | 컴포넌트 파일 옆 | panda.config.ts의 theme.recipes |
| import | from '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 표준 후보