06 Typed Variants — Panda codegen vs VariantProps, HTML attr 충돌, polymorphic
이 문서가 답하는 질문: variant prop의 타입은 어디서 오고, 어떻게 작성한 객체와 자동 동기화되는가. 그리고 그 타입이 HTML element의 attribute와 충돌하면 어떻게 되는가. 한 줄 답 (Pyramid Top): Panda는 빌드 단계 codegen으로
.d.ts를 생성하고, CVA/tv는VariantProps<typeof button>한 줄로 타입을 유추한다. 두 방식 모두 variants 객체가 SSOT고 타입이 그 그림자다. 진짜 함정은 variant prop 이름이<button type="submit">의type처럼 HTML 어휘와 부딪힐 때다.
Why — 왜 typed variants가 반드시 필요한가
variant 시스템의 핵심 가치 두 가지가 모두 타입에 달려 있다:
| 가치 | 타입 없이 | 타입과 함께 |
|---|---|---|
| 잘못된 값 차단 | 런타임에야 발견 | 컴파일 에러 |
| 자동완성 | docs를 매번 봄 | IDE가 보여줌 |
| 리네임 안전 | 사용처를 grep해서 일일이 | rename refactor 1번 |
| defaultVariants 인지 | 호출자가 정확한 동작 모름 | 호버하면 default 표시 |
타입이 없으면 variant 시스템은 그냥 *“className 만드는 함수”*에 불과하다. variants 객체와 그 타입이 한 호흡으로 흘러야 디자인 시스템의 본래 약속이 산다.
How — Panda CSS의 타입 생성: codegen 파이프라인
Panda는 panda codegen 명령으로 styled-system/ 폴더를 생성한다.
# package.json
{
"scripts": {
"prepare": "panda codegen",
"dev": "panda codegen --watch & next dev"
}
}생성 흐름
생성된 .d.ts 예시
// styled-system/recipes/button.d.ts (자동 생성)
import type { ConditionalValue } from '../types'
import type { Pretty } from '../types/helpers'
import type { DistributiveOmit } from '../types/system-types'
interface ButtonVariant {
size: 'sm' | 'md' | 'lg'
variant: 'primary' | 'ghost' | 'danger'
}
type ButtonVariantMap = {
[key in keyof ButtonVariant]: Array<ButtonVariant[key]>
}
export type ButtonVariantProps = {
[key in keyof ButtonVariant]?: ConditionalValue<ButtonVariant[key]>
}
export interface ButtonRecipe {
__type: ButtonVariantProps
(props?: ButtonVariantProps): string
raw: (props?: ButtonVariantProps) => ButtonVariantProps
variantMap: ButtonVariantMap
variantKeys: Array<keyof ButtonVariant>
splitVariantProps<Props extends ButtonVariantProps>(
props: Props
): [ButtonVariantProps, Pretty<DistributiveOmit<Props, keyof ButtonVariantProps>>]
}
export declare const button: ButtonRecipe두 가지 유용한 API
splitVariantProps— 사용자가 준 props 객체에서 variant 키와 나머지 props를 분리
import { button } from 'styled-system/recipes'
function Button(props) {
const [variantProps, restProps] = button.splitVariantProps(props)
return <button className={button(variantProps)} {...restProps} />
}variantKeys/variantMap— 런타임에 어떤 variants가 있는지 조회. Storybook 자동 controls 생성 등에 활용
How — CVA/tv의 타입 유추: VariantProps
CVA와 tv는 codegen이 없다. 대신 TypeScript의 조건부 타입으로 객체 형태에서 prop 타입을 유추한다.
import { cva, type VariantProps } from 'class-variance-authority'
const button = cva('inline-flex items-center', {
variants: {
size: { sm: 'h-8', md: 'h-10', lg: 'h-12' },
variant: {
primary: 'bg-blue-600 text-white',
ghost: 'bg-transparent',
},
},
defaultVariants: { size: 'md', variant: 'primary' },
})
// 핵심 한 줄
type ButtonVariants = VariantProps<typeof button>
// ^? { size?: 'sm' | 'md' | 'lg'; variant?: 'primary' | 'ghost' }VariantProps의 내부
// CVA 내부 (단순화)
type ConfigVariants<T> = T extends (props: infer P) => string ? P : never
export type VariantProps<T> = Omit<ConfigVariants<T>, 'class' | 'className'>즉, 함수의 첫 인자 타입에서 class/className을 제외한 것. 흑마법이 아니라 타입스크립트의 유추를 그대로 활용.
tailwind-variants의 차이
import { tv, type VariantProps } from 'tailwind-variants'
const button = tv({
base: '...',
variants: { ... },
})
type Props = VariantProps<typeof button>
// 결과는 CVA와 사실상 동일
// slots가 있으면 반환 타입이 다름 — 각 slot 함수의 객체
const card = tv({ slots: { root: '...', body: '...' }, variants: { ... } })
const { root, body } = card({ tone: 'emphasis' })
// root, body 각각이 함수What — 세 가지 타입 시스템 비교
| 항목 | Panda recipe | CVA | tailwind-variants |
|---|---|---|---|
| 타입 출처 | codegen .d.ts | TS inference | TS inference |
| 생성 시점 | panda codegen 실행 시 | 즉시 (작성하면 적용) | 즉시 |
undefined 허용 | optional (default 적용) | optional | optional |
| 유틸 함수 | splitVariantProps, variantMap | 없음 | 없음 |
| 자동 IDE 자동완성 | 매우 좋음 (description 표시) | 좋음 | 좋음 |
| 수동 sync 필요 | codegen 재실행 | 없음 | 없음 |
| 빌드 의존 | 있음 (CI에서 codegen 필요) | 없음 | 없음 |
What — HTML attribute 충돌의 함정
가장 흔한 함정. variant prop을 HTML 표준 attribute와 같은 이름으로 만들면, JSX 타이핑이 거짓말을 한다.
❌ 문제 코드
const button = cva('...', {
variants: {
type: { // ← HTML <button type="submit">의 type과 충돌
primary: 'bg-blue-600',
ghost: 'bg-transparent',
},
},
})
type ButtonProps = VariantProps<typeof button> &
React.ButtonHTMLAttributes<HTMLButtonElement>
// → type: 'primary' | 'ghost' | 'submit' | 'reset' | 'button' (intersection of broken)
<Button type="primary"> {/* 컴파일 통과, 런타임에 form submit 동작 깨짐 */}✅ 해결: 디자인 어휘로 분리
const button = cva('...', {
variants: {
variant: { // 또는 'tone', 'intent'
primary: 'bg-blue-600',
ghost: 'bg-transparent',
},
},
})
<Button type="submit" variant="primary"> {/* 둘이 독립 */}충돌하기 쉬운 이름들
| HTML attr | 의미 | 권장 대안 |
|---|---|---|
type | <button type="submit">, <input type="text"> | variant, tone, intent |
size | <input size="20">, <select size> | (Panda/CVA에서 흔히 충돌) → namespace 분리 또는 dimension |
color | <font color> (deprecated이지만 React가 경고) | tone, palette |
value | form value | (variant 이름으로 쓰지 말 것) |
disabled | 진짜 disabled | variants로 중복 표현하지 말기 — HTML attr 그대로 |
role | ARIA role | (variant 이름으로 쓰지 말 것) |
size 함정의 미묘함
<input>의 size는 글자 폭을 의미하는 진짜 HTML 속성이다. 따라서 Input 컴포넌트에서:
type InputProps = VariantProps<typeof input> & React.InputHTMLAttributes<HTMLInputElement>
// size: 'sm' | 'md' | 'lg' | number — 두 의미가 union해결책 둘:
- variant prop 이름을
dimension이나inputSize로 바꾼다 - HTML
size를 명시적으로 omit한다
type InputProps = VariantProps<typeof input> &
Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'>What — polymorphic component와 variant의 결합
<Button as="a"> 같은 polymorphic 패턴을 쓰면 variant 타입이 elementType마다 달라지는 복잡함이 생긴다.
import { cva, type VariantProps } from 'class-variance-authority'
import type { ComponentPropsWithoutRef, ElementType } from 'react'
const button = cva('...', { variants: { ... } })
type ButtonOwnProps<E extends ElementType> = VariantProps<typeof button> & {
as?: E
}
type ButtonProps<E extends ElementType> = ButtonOwnProps<E> &
Omit<ComponentPropsWithoutRef<E>, keyof ButtonOwnProps<E>>
export function Button<E extends ElementType = 'button'>({
as,
size,
variant,
...props
}: ButtonProps<E>) {
const Component = as ?? 'button'
return <Component className={button({ size, variant })} {...props} />
}
// 사용
<Button>Click</Button> // <button>
<Button as="a" href="/x">Link</Button> // <a> — href 인식polymorphic의 변수 E extends ElementType이 variant prop과 attribute 충돌을 더 미묘하게 만든다. 자세한 패턴은 05-composition 챕터에서.
What-if — typed variants에서 깨지는 경우
- 함정 1:
panda codegen안 돌리고defineRecipe수정 → 컴파일은 통과하지만 옛 타입 사용. CI에 codegen 단계 필수 - 함정 2:
VariantProps로 추출한 타입을 수동 union으로 또 정의 → 객체와 drift. 항상 typeof에서 유추// ❌ type Props = { size: 'sm' | 'md' | 'lg' /* 새 값 추가해도 여기 안 옴 */ } // ✅ type Props = VariantProps<typeof button> - 함정 3: HTML attr 충돌을 무시 → 형식상 동작하지만 의미가 깨짐
- 함정 4: optional variant에 default 없음 → 호출자가 undefined을 의식적으로 받게 됨. 항상
defaultVariants박기 - 함정 5: polymorphic +
as="input"에서 Input HTMLAttributes의 size와 variantsize가 충돌 → 명시적 Omit - 함정 6: Panda recipe의
.d.ts를 git에서 체크인해야 하는지 gitignore해야 하는지 — 권장은 gitignore + prepare script. 체크인하면 codegen 결과 PR이 더러워짐
Insight — 왜 codegen vs inference가 갈렸나
**Stitches(2020)**는 처음부터 inference 기반이었다. TS의 conditional/mapped 타입이 충분히 강해서 굳이 codegen이 필요 없었다. **CVA(2022)**는 그 길을 그대로 따랐다.
그런데 **Panda(2023)**는 codegen으로 돌아갔다. 왜?
| 이유 | 설명 |
|---|---|
| import 경로의 단순함 | from 'styled-system/recipes' 한 곳에서 모든 recipe 접근 |
| 타입 컴파일 속도 | 복잡한 inference는 TS 컴파일을 느리게 함. codegen은 이미 풀린 타입 |
| docs 통합 | description 필드, 미리보기 등 코드 너머의 메타데이터를 함께 생성 |
| 다국어/다단계 변환 | recipe → component stub, recipe → JSON schema 등 후속 변환이 용이 |
즉 inference는 순수 TS 안에서 끝나서 가볍지만, codegen은 디자인 시스템 전체의 SSOT를 한 폴더에 모을 수 있다. 디자인 시스템의 공개 패키지를 만드는 입장에서는 후자가 점점 매력적이다.
반전: 일부 팀은 둘 다를 한다 — Panda config recipe로 공개 컴포넌트를 codegen하고, CVA로 앱 한정 atomic recipe를 inference로 둔다. 책장의 층마다 다른 도구를 쓰는 것. 디자인 시스템 도구는 도그마가 아니라 규모와 책임의 함수다.
요약
- Panda = codegen, CVA/tv = inference — 둘 다 variants 객체가 SSOT
VariantProps<typeof X>는 typeof에서 유추하는 한 줄. 수동 union 금지- 진짜 함정은 HTML attribute와 이름 충돌 —
type,size,color조심 - namespace 분리(
variant,tone,intent) + 필요 시Omit<HTMLAttributes, 'size'> - 규모가 커지면 codegen 기반 config recipe로 가는 흐름이 명확하다