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 / lg | variants.size |
| 시각 강조 | primary / ghost / danger | variants.variant |
| 상호작용 상태 | hover / focus / disabled | CSS 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개 문서로 어떻게 쪼갰나
| # | 문서 | 다루는 것 | 분량 |
|---|---|---|---|
| 01 | Why Variants Exist | props 분기 vs className 직조의 역사·BEM의 한계·variant의 탄생 | ~9KB |
| 02 | Panda Recipe Deep Dive | 챕터 중심. cva() / defineRecipe / atomic vs config recipe / zero-runtime 추출 | ~14KB |
| 03 | CVA & tailwind-variants | class-variance-authority API, tv() API, slots, twMerge 통합 | ~13KB |
| 04 | Compound Variants & State | compoundVariants 매칭 우선순위, data-state, ARIA 상태와의 결합 | ~11KB |
| 05 | Recipe vs Utility Trade-offs | <Button variant> vs className="..." — 언제 어느 것 | ~11KB |
| 06 | Typed Variants | Panda 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로 둘 다 제공하는 전략