03 CVA & tailwind-variants — 런타임에서 className을 조립하는 두 가지 길
이 문서가 답하는 질문: Tailwind 기반 프로젝트에서 variant를 다루는 두 사실상 표준 라이브러리 — CVA와 tailwind-variants — 는 무엇이 같고 무엇이 다른가. 어느 것을 언제 고르는가. 한 줄 답 (Pyramid Top): 둘 다 **“variants 객체 → className 문자열 생성기”**로 동일한 패턴이다. CVA는 작고 의도된 미니멀, **tailwind-variants(tv)**는 CVA의 진화형 — slots 지원 + twMerge 내장이 결정적 차이다.
Why — Tailwind 진영에 왜 variant 라이브러리가 필요했나
Tailwind는 마크업에 의미 있는 utility를 약속한다. 그러나 같은 버튼이 앱 곳곳에 30번 등장하면 같은 utility 문자열을 30번 반복하게 된다. 디자이너가 “primary 색 톤을 살짝 다르게”라고 하면 30곳을 모두 고쳐야 한다.
| 풀려는 문제 | 이전의 해법 | 한계 |
|---|---|---|
| ”같은 utility 묶음 반복” | clsx()로 부분 묶음 | 분기 표현 어려움 |
| ”props에 따라 utility 묶음 교체” | if/else로 className 조립 | 타입 없음, 분기 폭발 |
| ”compound 조합 표현” | 별도 변수로 추가 | 어디서 적용했는지 흩어짐 |
| ”slot이 다른 컴포넌트의 일부” | 각 영역마다 별도 cva | slot 경계가 모호 |
CVA는 처음 세 가지를, tailwind-variants는 네 번째까지 해결한다.
How — CVA의 실제 코드
CVA는 함수 하나다. Joe Bell이 2022년 Stitches의 variants 부분만 떼어내 Tailwind 친화로 재작성한 미니멀 라이브러리.
import { cva, type VariantProps } from 'class-variance-authority'
const button = cva(
// ① base — 모든 variant 공통
'inline-flex items-center justify-center rounded font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:opacity-50 disabled:pointer-events-none',
{
// ② variants — 각 축의 값별 utility
variants: {
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-base',
lg: 'h-12 px-6 text-lg',
},
variant: {
primary: 'bg-blue-600 text-white hover:bg-blue-700',
ghost: 'bg-transparent text-blue-700 hover:bg-blue-50',
danger: 'bg-red-600 text-white hover:bg-red-700',
},
tone: {
solid: '',
outline: 'border-2 bg-transparent',
},
},
// ③ compoundVariants — 다축 조합 전용
compoundVariants: [
{
variant: 'primary',
tone: 'outline',
class: 'border-blue-600 text-blue-600 hover:bg-blue-50',
},
{
variant: 'danger',
tone: 'outline',
class: 'border-red-600 text-red-600 hover:bg-red-50',
},
],
// ④ defaultVariants — 호출자가 안 주면 이 값
defaultVariants: {
size: 'md',
variant: 'primary',
tone: 'solid',
},
}
)
// VariantProps로 prop 타입 자동 추론
type ButtonVariants = VariantProps<typeof button>
// { size?: 'sm' | 'md' | 'lg'; variant?: ...; tone?: ... }
type ButtonProps = ButtonVariants & React.ButtonHTMLAttributes<HTMLButtonElement>
export function Button({ size, variant, tone, className, ...rest }: ButtonProps) {
return <button className={button({ size, variant, tone, class: className })} {...rest} />
}CVA의 두 가지 특징
class키 (className아님) — compoundVariants와 호출 시 둘 다class:를 쓴다. JSX와 다른 점이라 처음엔 헷갈림- 별도 className merger 필요 — CVA 자체는 문자열 concat만 한다. Tailwind utility의 중복 해소(
p-4 p-6→p-6)는tailwind-merge의twMerge()나cn()헬퍼로 따로 처리
// shadcn/ui 스타일의 cn() 헬퍼
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
// 사용
<button className={cn(button({ variant }), className)} />How — tailwind-variants(tv)의 실제 코드
tailwind-variants는 CVA의 진화형이다. twMerge가 내장되어 있고, slots(여러 영역에 다른 className)을 1급으로 지원한다.
기본 사용 (CVA와 거의 동일)
import { tv, type VariantProps } from 'tailwind-variants'
const button = tv({
base: 'inline-flex items-center justify-center rounded font-medium transition-colors',
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-600 text-white hover:bg-blue-700',
ghost: 'bg-transparent text-blue-700 hover:bg-blue-50',
},
},
compoundVariants: [
{ variant: 'primary', size: 'lg', class: 'font-bold tracking-wide' },
],
defaultVariants: { size: 'md', variant: 'primary' },
})
type ButtonVariants = VariantProps<typeof button>
export function Button({ size, variant, className, ...rest }) {
return <button className={button({ size, variant, class: className })} {...rest} />
// ↑ twMerge가 내장이라 className에 'p-8'을 주면 base의 'px-4'를 덮어씀
}slots — tv의 결정적 차별점
한 컴포넌트가 여러 영역으로 구성될 때 (예: Card = Root + Header + Body + Footer):
import { tv } from 'tailwind-variants'
const card = tv({
slots: {
root: 'rounded-lg overflow-hidden border',
header: 'p-4 border-b font-semibold',
body: 'p-4',
footer: 'p-4 border-t bg-gray-50',
},
variants: {
tone: {
default: {
root: 'border-gray-200 bg-white',
header: 'bg-white',
},
emphasis: {
root: 'border-blue-200 bg-blue-50',
header: 'bg-blue-100 text-blue-900',
footer: 'bg-blue-100',
},
},
size: {
compact: {
header: 'p-2',
body: 'p-2',
footer: 'p-2',
},
cozy: {
header: 'p-4',
body: 'p-4',
footer: 'p-4',
},
},
},
defaultVariants: { tone: 'default', size: 'cozy' },
})
function Card({ tone, size, children }) {
const { root, header, body, footer } = card({ tone, size })
return (
<div className={root()}>
<div className={header()}>Title</div>
<div className={body()}>{children}</div>
<div className={footer()}>Footer</div>
</div>
)
}card({ tone, size })의 반환이 각 slot마다 함수가 들어있는 객체다. 각 함수를 호출해야 최종 className 문자열이 나온다 — 이 지연 호출이 추가 className을 inline으로 주입할 여지를 만든다.
What — 세 가지 시그니처를 나란히
같은 버튼을 세 라이브러리로 작성한 모습 (base와 variants만):
| 라이브러리 | API | 비고 |
|---|---|---|
Panda recipe (cva) | cva({ base: { ... CSS obj ... }, variants }) | CSS 객체 (camelCase) |
| CVA | cva('utility string', { variants }) | utility 문자열 |
| tailwind-variants | tv({ base: 'utility string', variants }) | utility 문자열 + twMerge |
시그니처 차이의 함의
// Panda — 입력이 CSS-in-JS 객체 (camelCase)
cva({
base: { display: 'inline-flex', backgroundColor: 'primary' },
})
// CVA / tv — 입력이 Tailwind utility 문자열
cva('inline-flex bg-blue-600', { ... })
tv({ base: 'inline-flex bg-blue-600', ... })Panda는 Panda의 토큰 시스템을 권위로 두고, CVA/tv는 Tailwind의 utility 카탈로그를 권위로 둔다. 즉, 토큰 출처가 다르다는 점이 근본 차이다.
What — CVA와 tv의 정확한 차이표
| 항목 | CVA | tailwind-variants |
|---|---|---|
| 번들 크기 | ~1KB | ~3KB (twMerge 포함) |
| base 시그니처 | 첫 인자 | options.base |
| slots 지원 | ❌ | ✅ |
| twMerge 내장 | ❌ (별도 cn() 필요) | ✅ |
| screens(반응형 variant) | ❌ | ✅ |
compound class 키 | ✅ | ✅ |
| TypeScript 추론 | VariantProps | VariantProps |
| dependency | clsx | tailwind-merge |
| 권장 상황 | 단순 컴포넌트, 의도된 미니멀 | 멀티 slot 컴포넌트, Tailwind 풀스택 |
What — compound + screens (tv 전용)
tv는 반응형 variant도 지원한다 — variant 값을 breakpoint별로 다르게:
const button = tv({
base: 'inline-flex items-center',
variants: {
size: { sm: 'h-8', md: 'h-10', lg: 'h-12' },
},
})
// 호출 시 객체로 반응형 지정
<button className={button({
size: { initial: 'sm', md: 'md', lg: 'lg' }
})} />
// → 'h-8 md:h-10 lg:h-12'CVA에는 이 기능이 없다 — Tailwind responsive prefix를 수동으로 utility에 박아야 함.
What-if — 둘 다 잘못 쓰면
-
함정 1:
classNamevsclass키 혼동// ❌ JSX는 className, CVA/tv는 class compoundVariants: [{ variant: 'primary', className: '...' }] // 무시됨 // ✅ compoundVariants: [{ variant: 'primary', class: '...' }] -
함정 2:
defaultVariants누락 → SSR/CSR mismatch// ❌ size가 optional이지만 default 없음 const button = tv({ variants: { size: {...} } }) // 서버: size=undefined → 'inline-flex' 만 // 클라: useState로 'md' → 'inline-flex h-10 px-4' // → hydration mismatch -
함정 3: CVA에서 twMerge 안 쓰고 외부 className 받기
// ❌ p-4와 p-8이 둘 다 className에 들어감 (순서에 따라 운) <button className={`${button({ size: 'md' })} ${userClass}`} /> // ✅ cn()/twMerge 사용 <button className={cn(button({ size: 'md' }), userClass)} /> -
함정 4: Tailwind JIT가 동적 클래스 문자열을 못 잡음
// ❌ Tailwind 빌드는 'bg-blue-' + n을 파싱 못 함 const button = cva(`bg-blue-${shade}`) // ✅ 명시적 분기 const colorMap = { 500: 'bg-blue-500', 600: 'bg-blue-600' } -
함정 5: slot의 함수 반환을 잊고 직접 사용
const { root, header } = card({ tone }) return <div className={root}>...</div> // ❌ root는 함수 return <div className={root()}>...</div> // ✅ 호출
Insight — CVA와 tv는 경쟁자인가 후계자인가
연표:
| 연도 | 이벤트 |
|---|---|
| 2020 | Stitches 출시 (Modulz) — variants 패턴의 원형 |
| 2022.01 | CVA by Joe Bell — Stitches의 variants만 떼어 Tailwind용으로 |
| 2022.10 | shadcn/ui 등장 — CVA를 표준으로 채택 → 폭발적 보급 |
| 2023.03 | tailwind-variants by Junior Garcia (NextUI/HeroUI 메인테이너) |
| 2024 | NextUI(HeroUI)·shadcn 일부가 tv로 마이그레이션 |
Joe Bell 본인이 GitHub에서 “tv는 CVA의 자연스러운 진화형”이라고 언급했다. 즉 경쟁이 아니라 세대 교체에 가깝다. 다만 다음 두 경우 CVA가 여전히 합리적:
- 최소 의존성이 중요한 라이브러리 작성자 — 1KB vs 3KB
- slots가 필요 없는 단순 컴포넌트 — 추가 기능을 안 쓰면 학습/번들 모두 손해
흥미로운 관전 포인트: Panda CSS가 두 진영의 입력 문법을 모두 흡수하려 한다. cva()는 CSS 객체를, defineRecipe()는 더 풍부한 메타를 받지만, 정작 API의 모양은 CVA와 거의 동형이다. 즉, CVA가 de facto API 표준을 만들었고 Panda는 그걸 빌드 타임 버전으로 재해석한 셈이다.
요약
- CVA와 tv는 같은 API 패턴의 두 세대
- 차이의 핵심은 slots + twMerge 내장 + 반응형 variant
- 1KB가 중요하면 CVA, slot이 있으면 tv, 빌드 타임 추출이 필요하면 Panda
- 어느 쪽이든 variant 객체로 className을 만든다는 추상은 사실상 표준