🧩 Design System4. Recipes & Variants03 CVA & tailwind-variants — 런타임에서 className을 조립하는 두 가지 길

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이 다른 컴포넌트의 일부”각 영역마다 별도 cvaslot 경계가 모호

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의 두 가지 특징

  1. class 키 (className 아님) — compoundVariants와 호출 시 둘 다 class:를 쓴다. JSX와 다른 점이라 처음엔 헷갈림
  2. 별도 className merger 필요 — CVA 자체는 문자열 concat만 한다. Tailwind utility의 중복 해소(p-4 p-6p-6)는 tailwind-mergetwMerge()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)
CVAcva('utility string', { variants })utility 문자열
tailwind-variantstv({ 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의 정확한 차이표

항목CVAtailwind-variants
번들 크기~1KB~3KB (twMerge 포함)
base 시그니처첫 인자options.base
slots 지원
twMerge 내장❌ (별도 cn() 필요)
screens(반응형 variant)
compound class
TypeScript 추론VariantPropsVariantProps
dependencyclsxtailwind-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: className vs class 키 혼동

    // ❌ 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는 경쟁자인가 후계자인가

연표:

연도이벤트
2020Stitches 출시 (Modulz) — variants 패턴의 원형
2022.01CVA by Joe Bell — Stitches의 variants만 떼어 Tailwind용으로
2022.10shadcn/ui 등장 — CVA를 표준으로 채택 → 폭발적 보급
2023.03tailwind-variants by Junior Garcia (NextUI/HeroUI 메인테이너)
2024NextUI(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을 만든다는 추상은 사실상 표준