🧩 Design System4. Recipes & Variants06 Typed Variants — Panda codegen vs VariantProps, HTML attr 충돌, polymorphic

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 recipeCVAtailwind-variants
타입 출처codegen .d.tsTS inferenceTS inference
생성 시점panda codegen 실행 시즉시 (작성하면 적용)즉시
undefined 허용optional (default 적용)optionaloptional
유틸 함수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
valueform value(variant 이름으로 쓰지 말 것)
disabled진짜 disabledvariants로 중복 표현하지 말기 — HTML attr 그대로
roleARIA role(variant 이름으로 쓰지 말 것)

size 함정의 미묘함

<input>size글자 폭을 의미하는 진짜 HTML 속성이다. 따라서 Input 컴포넌트에서:

type InputProps = VariantProps<typeof input> & React.InputHTMLAttributes<HTMLInputElement>
// size: 'sm' | 'md' | 'lg' | number — 두 의미가 union

해결책 둘:

  1. variant prop 이름을 dimension이나 inputSize로 바꾼다
  2. 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와 variant size가 충돌 → 명시적 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로 가는 흐름이 명확하다