🧩 Design System4. Recipes & Variants📖 개요

04-recipes-variants — variant 시스템과 Panda recipe / CVA / tailwind-variants

이 챕터가 답하는 질문: 디자이너가 말하는 “Primary, Large, Disabled”가 코드에서는 정확히 어떤 모양으로 직렬화되어야 하는가. 그리고 같은 일을 하는 Panda recipe, CVA, tailwind-variants 셋 중 무엇을 언제 쓰는가. 작성: 2026-05-19 / 분량: 6개 본문 + 인덱스


한 문장 답 (Pyramid Top)

variant = 디자인 의사결정의 직렬화 단위다. 디자이너가 Figma 컴포넌트 패널에서 토글하는 Size, Variant, State 각각은 코드 세계에서 cva({ variants: { size, variant, state } }) 라는 함수형 contract으로 박제된다. 그 contract을 빌드 타임에 CSS로 추출하면 Panda recipe, 런타임에 className으로 조립하면 CVA / tailwind-variants다.


Why — 왜 variant 챕터가 디자인 시스템의 분기점인가

토큰(color.primary)은 값의 약속이고, recipe·variant는 그 값을 조합한 규칙의 약속이다. 디자인 시스템에서 “버튼 하나”를 만드는 작업은 사실 다음 네 가지가 동시에 결정되는 교집합이다:

결정예시어디에 박제되는가
크기sm / md / lgvariants.size
시각 강조primary / ghost / dangervariants.variant
상호작용 상태hover / focus / disabledCSS pseudo + data-state
조합 규칙”danger + lg 일 때만 폰트 굵게”compoundVariants

이 네 가지를 className 문자열로 직조하면 btn btn-lg btn-primary btn-hover 같은 BEM이 된다(Bootstrap 시대). 그러나 BEM은 타입이 없고, 컴포넌트마다 규칙이 흩어지며, 잘못된 조합을 막을 수 없다(예: btn-primary-ghost).

variant 시스템은 이 모든 결정을 하나의 함수형 객체(cva())로 모은 뒤, 빌드 타임 또는 런타임에 className을 기계적으로 생성한다. 즉:

  • 타입: size: "sm" | "md" | "lg"로 잘못된 값은 컴파일 에러
  • 응집: 한 컴포넌트의 모든 분기가 한 객체 안에
  • 검증: compoundVariants가 “이 조합만 허용”을 명시

이 챕터는 그 contract을 왜·어떻게·무엇으로 푸는지를 6개 문서로 분해한다.


How — 6개 문서로 어떻게 쪼갰나

#문서다루는 것분량
01Why Variants Existprops 분기 vs className 직조의 역사·BEM의 한계·variant의 탄생~9KB
02Panda Recipe Deep Dive챕터 중심. cva() / defineRecipe / atomic vs config recipe / zero-runtime 추출~14KB
03CVA & tailwind-variantsclass-variance-authority API, tv() API, slots, twMerge 통합~13KB
04Compound Variants & StatecompoundVariants 매칭 우선순위, data-state, ARIA 상태와의 결합~11KB
05Recipe vs Utility Trade-offs<Button variant> vs className="..." — 언제 어느 것~11KB
06Typed VariantsPanda codegen, VariantProps<typeof button>, polymorphic prop과의 충돌~9KB

What — 세 라이브러리의 한 줄 비교

┌─────────────────────────────────────────────────────────────┐
│ Panda CSS recipe                                            │
│   import { cva } from 'styled-system/css'                   │
│   - 빌드 타임 CSS 추출 (zero-runtime)                          │
│   - 타입: codegen으로 자동 생성                                  │
│   - 토큰: panda.config.ts의 tokens 직접 참조                    │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ class-variance-authority (CVA)                              │
│   import { cva } from 'class-variance-authority'            │
│   - 런타임 className 조립                                      │
│   - 타입: VariantProps<typeof button> 유틸                     │
│   - 마크업: Tailwind utility 클래스 직접 작성                    │
└─────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────┐
│ tailwind-variants (tv)                                      │
│   import { tv } from 'tailwind-variants'                    │
│   - 런타임 + slots(다중 영역) 지원                              │
│   - twMerge 내장(중복 utility 자동 해소)                        │
│   - CVA의 진화형 — slot이 필요하면 이걸                          │
└─────────────────────────────────────────────────────────────┘

세 라이브러리 모두 API 형태가 거의 같다cva() 또는 tv()base + variants + compoundVariants + defaultVariants를 받는 같은 모양. 다른 것은 언제 className이 결정되는가(빌드 vs 런타임)와 무엇이 권위 있는 토큰 출처인가(panda.config vs tailwind.config)뿐이다.


What-if — variant를 잘못 다루면

  • 함정 1: compoundVariants의 매칭 우선순위 미스 → 두 규칙이 충돌해 마지막 정의가 이긴다. 명시적으로 분리하지 않으면 CSS specificity 디버깅 지옥
  • 함정 2: defaultVariants 없이 prop optional 처리 → SSR에서는 undefined로 렌더, CSR에서는 React state로 바뀌어 hydration mismatch
  • 함정 3: variant prop과 HTML attribute 이름 충돌 — <Button type="primary"><button type="primary">로 새서 form submit 동작 깨짐. 해결: variant prop은 variant, tone, intent 같은 디자인 어휘
  • 함정 4: recipe와 utility를 같은 className으로 합칠 때 specificity 경쟁 → Panda는 layer로, CVA는 cn()/twMerge로 해소
  • 함정 5: variant 값을 동적으로 — <Button variant={`primary-${size}`}> 같은 string concat은 Tailwind JIT에 잡히지 않아 클래스가 통째로 사라진다

Insight — variant는 디자인 시스템의 “API 표면”이다

토큰이 값의 단일 출처라면, recipe·variant는 컴포넌트 사용자(앱 개발자)와 마주하는 API 표면이다. 즉, 한 번 공개된 variant: "primary" | "ghost" | "danger"공개 인터페이스이고, 이름 하나를 바꾸면 모든 호출처가 깨진다.

이 점에서 variant 설계는 SemVer 거버넌스 문제와 직결된다:

  • 이름 추가 → minor
  • 이름 변경/제거 → major + codemod
  • compound 추가 → patch (시각적 변화만)

또 흥미로운 것은 Stitches → CVA → Panda → tailwind-variants계보다. 모두 같은 사람(JoeBell, segunadebayo 등)의 손을 거쳐 진화했고, API가 거의 동일한 이유는 그 계보 때문이다. 자세한 이야기는 02번 문서의 Insight에서.


다음 챕터

  • 05-composition — recipe의 slot 개념을 polymorphic component까지 확장
  • 06-theming — variant가 테마 컨텍스트에 따라 어느 토큰을 가리키는가
  • 07-panda-tailwind-interop — 같은 컴포넌트를 recipe와 utility로 둘 다 제공하는 전략