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) | 타입 없음, 잘못된 조합 가능 |
| ”한 컴포넌트의 모든 분기를 한 곳에” | props로 if/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 + variants | data-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:
variant와tone과intent가 한 시스템에 섞임 — 어휘 통일이 안 되면 사용자는 매번 docs를 본다 - 함정 3: variant가 너무 많다 — 8 variants × 4 sizes × 3 tones = 96개 조합. 디자이너가 모두 검수하지 못함. 축을 줄이거나 compound로 한정
- 함정 4: variant가 너무 적다 —
variant: 'primary' | 'secondary'로 모든 걸 쥐어짜면 결국classNameoverride가 늘어남 - 함정 5: variant prop을 HTML attribute와 같은 이름으로 —
<Button type="primary">가<button type="primary">로 새서 form submit이 깨짐. 디자인 어휘는 HTML과 다른 이름으로
Insight — variant의 계보
| 연도 | 사건 | 영향 |
|---|---|---|
| 2013 | Bootstrap 3 — BEM 스타일 클래스 | 마크업에 의미 직조의 시작 |
| 2017 | styled-components — props로 동적 CSS | ”JS로 스타일을 분기” |
| 2020 | Stitches — variants API 도입 | cva()의 원형 |
| 2022 | CVA by Joe Bell — Stitches 분리·일반화 | Tailwind 진영에 variant 가져옴 |
| 2023 | Panda CSS by segunadebayo (Chakra 메인테이너) | Stitches 계보를 zero-runtime으로 |
| 2024 | tailwind-variants — CVA + slots + twMerge | CVA의 진화 |
흥미로운 점:
cva()라는 함수 이름과 시그니처가 네 라이브러리에 거의 그대로 살아남았다. 즉, “variants 객체로 className을 만든다”는 합의는 끝났고, 차이는 언제 className이 결정되는가와 어떤 토큰 시스템을 권위로 두는가에 있을 뿐이다.
이 합의가 끝났기 때문에 디자인 시스템 작성자는 세 라이브러리 중 무엇을 쓰든 같은 추상을 디자이너에게 약속할 수 있다. 그게 이 챕터 나머지가 셋을 나란히 다루는 이유.
요약
- BEM·props 분기·object lookup은 모두 variant 정보의 부분 표현이었다
cva()는 그 정보를 한 함수 호출로 모아 타입·compound·default를 동시에 약속한다- 같은 API가 Stitches → CVA → Panda → tv로 4번 반복되었다는 사실은, 이 합의가 사실상 표준이 되었음을 보여준다