🧩 Design System4. Recipes & Variants01 Why Variants Exist — props로 분기인가, className으로 직조인가

01 Why Variants Exist — props로 분기인가, className으로 직조인가

이 문서가 답하는 질문: 왜 우리는 <Button variant="primary" size="lg"> 같은 변종 객체를 만들게 되었나. 그 이전엔 무엇이 있었고 왜 부족했는가. 한 줄 답 (Pyramid Top): variant는 “디자인 의사결정을 props로 직렬화한 atomic unit” 이다. BEM이 마크업에 직조하던 같은 정보를 타입 가능한 함수형 contract으로 끌어올린 것.


Why — 왜 존재하는가

2010년대 초, 컴포넌트 라이브러리들은 마크업에 모든 의미를 직조했다. Bootstrap의 btn btn-lg btn-primary disabled는 인간이 읽기 좋았지만, 코드 입장에선 4개의 독립 클래스였다 — 잘못 조합해도 컴파일러는 침묵했다.

풀려는 문제이전의 해법한계
”크기·강조·상태를 마크업에 표현”BEM (btn--primary btn--lg)타입 없음, 잘못된 조합 가능
”한 컴포넌트의 모든 분기를 한 곳에”propsif/switch분기 폭발, JSX가 더러워짐
”디자이너의 Figma variant 패널과 1:1”문서로 명세drift 발생, 검증 불가
”스타일 결정의 SSOT”inline style, CSS-in-JS 직접 작성같은 결정이 여러 파일에 흩어짐

variant 시스템은 이 네 가지를 한 함수 호출로 모은다 — cva(). 함수 인자(variants 객체)가 디자인 패널의 직렬화가 되고, 반환된 함수가 className 생성기가 된다.


How — 어떻게 동작하는가

같은 버튼을 네 가지 방식으로 표현해보자.

① BEM (2013~)

<button class="btn btn--primary btn--lg btn--disabled">Save</button>
.btn { padding: 8px 16px; }
.btn--lg { padding: 12px 24px; }
.btn--primary { background: blue; color: white; }
.btn--disabled { opacity: 0.5; pointer-events: none; }

문제: btn--primary-lg처럼 조합 전용 스타일이 필요하면 새 클래스를 또 만들어야 함. 타입 없음.

② Props + if/switch (2015~ React 초기)

function Button({ size, variant, disabled }) {
  let className = 'btn'
  if (size === 'lg') className += ' btn-lg'
  if (variant === 'primary') className += ' btn-primary'
  if (disabled) className += ' btn-disabled'
  return <button className={className} disabled={disabled} />
}

문제: 분기 폭발. 5개 variants × 3 sizes × 4 states = 60줄의 if. 한 곳에서 빠뜨리면 silent fail.

③ Object lookup (2018~)

const sizeClasses = { sm: 'btn-sm', md: 'btn-md', lg: 'btn-lg' }
const variantClasses = { primary: 'btn-primary', ghost: 'btn-ghost' }
 
function Button({ size = 'md', variant = 'primary' }) {
  return <button className={`btn ${sizeClasses[size]} ${variantClasses[variant]}`} />
}

문제: compound(primary + lg 조합 전용)를 표현할 곳이 없다. defaultVariants도 ad-hoc.

④ variant 시스템 (cva(), 2022~)

import { cva, type VariantProps } from 'class-variance-authority'
 
const button = cva('inline-flex items-center rounded font-medium', {
  variants: {
    size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-6 text-lg' },
    variant: {
      primary: 'bg-blue-500 text-white hover:bg-blue-600',
      ghost: 'bg-transparent text-blue-600 hover:bg-blue-50',
      danger: 'bg-red-500 text-white hover:bg-red-600',
    },
  },
  compoundVariants: [
    { variant: 'primary', size: 'lg', class: 'font-bold tracking-wide' },
  ],
  defaultVariants: { size: 'md', variant: 'primary' },
})
 
type ButtonProps = VariantProps<typeof button>
// { size?: 'sm' | 'md' | 'lg'; variant?: 'primary' | 'ghost' | 'danger' }
 
function Button({ size, variant, ...props }: ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>) {
  return <button className={button({ size, variant })} {...props} />
}

얻은 것:

  • 타입: 잘못된 값은 컴파일 에러
  • 응집: 모든 분기가 한 객체에
  • compound: 조합 전용 스타일을 명시
  • default: 누락 시 동작이 결정적
  • VariantProps: props 타입 자동 추론

What — variant 시스템이 책임지는 4가지

책임표현예시
단일 축 분기variants.{axis}.{value}size: 'sm' | 'md' | 'lg'
다축 조합 규칙compoundVariants”primary + lg 일 때만 굵게”
누락 처리defaultVariants호출자가 size 안 주면 md
상태 표현data-attribute + variantsdata-state="open"을 variant로
// 4가지 책임을 한 객체에
const card = cva('rounded border', {
  variants: {
    tone: { default: 'bg-white border-gray-200', emphasis: 'bg-blue-50 border-blue-200' },
    padding: { sm: 'p-3', md: 'p-5', lg: 'p-8' },
  },
  compoundVariants: [
    { tone: 'emphasis', padding: 'lg', class: 'shadow-lg' },
  ],
  defaultVariants: { tone: 'default', padding: 'md' },
})

이 한 객체가 곧 Figma의 variant 패널과 1:1로 대응되어야 한다. 그게 디자이너-개발자 contract의 핵심.


What-if — variant를 도입했는데도 깨지는 경우

  • 함정 1: variant 이름이 디자인 의도가 아니라 현재 스타일을 가리킴 — variant: "blue"보다 variant: "primary". 색 바꾸면 이름 거짓말이 됨
  • 함정 2: varianttoneintent가 한 시스템에 섞임 — 어휘 통일이 안 되면 사용자는 매번 docs를 본다
  • 함정 3: variant가 너무 많다 — 8 variants × 4 sizes × 3 tones = 96개 조합. 디자이너가 모두 검수하지 못함. 축을 줄이거나 compound로 한정
  • 함정 4: variant가 너무 적다variant: 'primary' | 'secondary'로 모든 걸 쥐어짜면 결국 className override가 늘어남
  • 함정 5: variant prop을 HTML attribute와 같은 이름으로 — <Button type="primary"><button type="primary">로 새서 form submit이 깨짐. 디자인 어휘는 HTML과 다른 이름으로

Insight — variant의 계보

연도사건영향
2013Bootstrap 3 — BEM 스타일 클래스마크업에 의미 직조의 시작
2017styled-components — props로 동적 CSS”JS로 스타일을 분기”
2020Stitches — variants API 도입cva()의 원형
2022CVA by Joe Bell — Stitches 분리·일반화Tailwind 진영에 variant 가져옴
2023Panda CSS by segunadebayo (Chakra 메인테이너)Stitches 계보를 zero-runtime으로
2024tailwind-variants — CVA + slots + twMergeCVA의 진화

흥미로운 점: cva()라는 함수 이름과 시그니처가 네 라이브러리에 거의 그대로 살아남았다. 즉, “variants 객체로 className을 만든다”는 합의는 끝났고, 차이는 언제 className이 결정되는가어떤 토큰 시스템을 권위로 두는가에 있을 뿐이다.

이 합의가 끝났기 때문에 디자인 시스템 작성자는 세 라이브러리 중 무엇을 쓰든 같은 추상을 디자이너에게 약속할 수 있다. 그게 이 챕터 나머지가 셋을 나란히 다루는 이유.


요약

  • BEM·props 분기·object lookup은 모두 variant 정보의 부분 표현이었다
  • cva()는 그 정보를 한 함수 호출로 모아 타입·compound·default를 동시에 약속한다
  • 같은 API가 Stitches → CVA → Panda → tv로 4번 반복되었다는 사실은, 이 합의가 사실상 표준이 되었음을 보여준다