junyeokk
Blog
React Ecosystem·2025. 11. 15

class-variance-authority (CVA)

UI 컴포넌트를 만들다 보면 같은 컴포넌트인데 상황에 따라 다른 스타일을 적용해야 하는 경우가 많다. 버튼 하나만 봐도 primary, secondary, danger 같은 색상 변형이 있고, small, medium, large 같은 크기 변형이 있다. 여기에 disabled 상태까지 합치면 조합이 기하급수적으로 늘어난다.

전통적으로 이걸 처리하는 방식은 조건부 클래스를 직접 조합하는 것이다.

tsx
function Button({ intent, size, disabled, className, ...props }) {
  return (
    <button
      className={[
        "font-semibold border rounded",
        intent === "primary" && "bg-blue-500 text-white border-transparent",
        intent === "secondary" && "bg-white text-gray-800 border-gray-400",
        size === "small" && "text-sm py-1 px-2",
        size === "medium" && "text-base py-2 px-4",
        disabled && "opacity-50 cursor-not-allowed",
        !disabled && intent === "primary" && "hover:bg-blue-600",
        !disabled && intent === "secondary" && "hover:bg-gray-100",
        className,
      ]
        .filter(Boolean)
        .join(" ")}
      {...props}
    />
  );
}

이 코드에는 몇 가지 문제가 있다. 변형이 추가될 때마다 조건문이 늘어나서 가독성이 떨어지고, 두 변형이 동시에 적용될 때의 스타일(compound variant)을 처리하려면 조건이 더 복잡해진다. 타입 안전성도 없다. intent"danger"를 넘겨도 TypeScript가 잡아주지 않는다. 무엇보다 스타일 정의와 렌더링 로직이 뒤섞여서 컴포넌트가 비대해진다.

CSS-in-JS 라이브러리인 Stitches나 Vanilla Extract는 이 문제를 variants API로 깔끔하게 해결했다. 변형을 선언적으로 정의하면 라이브러리가 알아서 클래스를 생성해준다. 하지만 CSS-in-JS를 쓸 수 없는 환경이 있다. Tailwind CSS를 사용하거나, CSS Modules을 선호하거나, 순수 CSS를 직접 작성하는 경우다. class-variance-authority(CVA)는 바로 이 지점을 파고든다. Stitches의 variants API를 클래스 문자열 세계로 가져온 것이다.


핵심 개념

CVA의 핵심은 cva 함수 하나다. 이 함수는 기본 클래스, 변형 정의, 복합 변형, 기본값을 받아서 variant 조합에 맞는 클래스 문자열을 반환하는 함수를 만들어준다.

typescript
import { cva } from "class-variance-authority";

const button = cva(base, config);

cva가 반환하는 것은 함수다. 이 함수에 variant 값을 넘기면 그에 맞는 클래스 문자열이 나온다. 런타임에 동적으로 클래스를 결정하는 것이 아니라, 미리 정의된 매핑에서 조회하는 방식이라 예측 가능하고 디버깅이 쉽다.


기본 사용법

버튼 컴포넌트를 예로 들어보자.

typescript
import { cva } from "class-variance-authority";

const button = cva(["font-semibold", "border", "rounded"], {
  variants: {
    intent: {
      primary: ["bg-blue-500", "text-white", "border-transparent"],
      secondary: ["bg-white", "text-gray-800", "border-gray-400"],
      danger: ["bg-red-500", "text-white", "border-transparent"],
    },
    size: {
      small: ["text-sm", "py-1", "px-2"],
      medium: ["text-base", "py-2", "px-4"],
      large: ["text-lg", "py-3", "px-6"],
    },
  },
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});

첫 번째 인자는 기본 클래스다. 어떤 변형을 선택하든 항상 적용되는 클래스들이다. 문자열 하나("font-semibold border rounded")로 넘겨도 되고, 배열로 넘겨도 된다. 배열을 쓰면 클래스가 많아질 때 가독성이 좋다.

두 번째 인자 config 객체에 변형을 정의한다.

variants

variants 객체의 각 키가 하나의 변형 축이 된다. 위 예시에서는 intentsize 두 축이 있고, 각 축에 선택 가능한 값들이 정의되어 있다. 값은 문자열이나 배열로 지정한다.

defaultVariants

변형 값을 넘기지 않았을 때 적용될 기본값이다. button()처럼 아무 인자 없이 호출하면 defaultVariants에 정의된 값이 사용된다.

typescript
button();
// => "font-semibold border rounded bg-blue-500 text-white border-transparent text-base py-2 px-4"

button({ intent: "secondary", size: "small" });
// => "font-semibold border rounded bg-white text-gray-800 border-gray-400 text-sm py-1 px-2"

button({ intent: "danger" });
// => "font-semibold border rounded bg-red-500 text-white border-transparent text-base py-2 px-4"
// size는 defaultVariants에서 "medium"이 적용됨

Boolean 변형

변형 값이 true/false 두 가지뿐인 경우 boolean variant를 사용한다. disabled, loading, fullWidth 같은 on/off 성격의 변형에 유용하다.

typescript
const button = cva("font-semibold border rounded", {
  variants: {
    intent: {
      primary: "bg-blue-500 text-white",
      secondary: "bg-white text-gray-800",
    },
    disabled: {
      false: null,
      true: "opacity-50 cursor-not-allowed pointer-events-none",
    },
    fullWidth: {
      false: "w-auto",
      true: "w-full",
    },
  },
  defaultVariants: {
    intent: "primary",
    disabled: false,
    fullWidth: false,
  },
});

button({ disabled: true });
// => "font-semibold border rounded bg-blue-500 text-white opacity-50 cursor-not-allowed pointer-events-none w-auto"

false 값에 null을 넣으면 해당 상태에서는 아무 클래스도 추가되지 않는다. boolean variant의 키는 문자열 "false""true"지만, 사용할 때는 JS boolean true/false로 넘기면 된다.


Compound Variants

두 개 이상의 변형이 특정 조합일 때만 적용되는 스타일이 compound variant다. "primary이면서 disabled가 아닐 때만 hover 효과를 넣고 싶다"는 요구사항을 처리할 수 있다.

typescript
const button = cva("font-semibold border rounded", {
  variants: {
    intent: {
      primary: "bg-blue-500 text-white border-transparent",
      secondary: "bg-white text-gray-800 border-gray-400",
    },
    size: {
      small: "text-sm py-1 px-2",
      medium: "text-base py-2 px-4",
    },
    disabled: {
      false: null,
      true: "opacity-50 cursor-not-allowed",
    },
  },
  compoundVariants: [
    {
      intent: "primary",
      disabled: false,
      class: "hover:bg-blue-600",
    },
    {
      intent: "secondary",
      disabled: false,
      class: "hover:bg-gray-100",
    },
    {
      intent: "primary",
      size: "medium",
      class: "uppercase",
    },
  ],
  defaultVariants: {
    intent: "primary",
    size: "medium",
    disabled: false,
  },
});

compoundVariants는 배열이다. 각 항목에 조건(variant 키-값 쌍)과 적용할 클래스(class 또는 className)를 정의한다. 조건의 모든 키-값이 일치할 때만 해당 클래스가 추가된다.

조건 값에 배열을 넣으면 OR 조건으로 동작한다.

typescript
compoundVariants: [
  {
    intent: ["primary", "danger"],
    size: "medium",
    class: "uppercase tracking-wide",
  },
],

이렇게 하면 intent가 primary이거나 danger이면서 size가 medium일 때 스타일이 적용된다. 조건문으로 일일이 분기하던 것을 선언적으로 표현할 수 있어서 복잡한 디자인 시스템에서 큰 차이를 만든다.


TypeScript 통합

CVA의 가장 실용적인 기능 중 하나가 VariantProps 타입이다. cva로 정의한 변형에서 자동으로 props 타입을 추출해준다.

typescript
import { cva, type VariantProps } from "class-variance-authority";

const buttonVariants = cva("font-semibold border rounded", {
  variants: {
    intent: {
      primary: "bg-blue-500 text-white",
      secondary: "bg-white text-gray-800",
      danger: "bg-red-500 text-white",
    },
    size: {
      small: "text-sm py-1 px-2",
      medium: "text-base py-2 px-4",
      large: "text-lg py-3 px-6",
    },
  },
  defaultVariants: {
    intent: "primary",
    size: "medium",
  },
});

type ButtonVariants = VariantProps<typeof buttonVariants>;
// {
//   intent?: "primary" | "secondary" | "danger" | null | undefined;
//   size?: "small" | "medium" | "large" | null | undefined;
// }

defaultVariants가 있는 속성은 optional(?)이 된다. 기본값이 없는 변형은 required다. 이 타입을 컴포넌트의 props 타입에 합성하면 된다.

tsx
import { cn } from "@/lib/utils";

interface ButtonProps
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

function Button({ intent, size, className, ...props }: ButtonProps) {
  return (
    <button
      className={cn(buttonVariants({ intent, size }), className)}
      {...props}
    />
  );
}

이제 <Button intent="primary" size="large" />처럼 사용할 때 자동완성이 되고, intent="invalid"를 넘기면 컴파일 에러가 난다. variant 정의를 수정하면 타입도 자동으로 따라가니까 정의와 타입이 어긋날 일이 없다.


React 컴포넌트 패턴

실제 프로젝트에서 CVA를 사용하는 전형적인 패턴은 variant 정의와 컴포넌트를 같은 파일에 두는 것이다. shadcn/ui가 이 패턴을 대중화했다.

tsx
// components/ui/badge.tsx
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";

const badgeVariants = cva(
  "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
  {
    variants: {
      variant: {
        default: "border-transparent bg-primary text-primary-foreground",
        secondary: "border-transparent bg-secondary text-secondary-foreground",
        destructive: "border-transparent bg-destructive text-destructive-foreground",
        outline: "text-foreground",
      },
    },
    defaultVariants: {
      variant: "default",
    },
  }
);

interface BadgeProps
  extends React.HTMLAttributes<HTMLDivElement>,
    VariantProps<typeof badgeVariants> {}

function Badge({ className, variant, ...props }: BadgeProps) {
  return (
    <div className={cn(badgeVariants({ variant }), className)} {...props} />
  );
}

export { Badge, badgeVariants };

여기서 주목할 점이 두 가지 있다.

badgeVariants를 export한다. 컴포넌트뿐 아니라 variant 함수도 같이 내보낸다. 다른 곳에서 이 배지의 스타일만 재사용하고 싶을 때(예: 다른 태그에 같은 스타일 적용) 유용하다.

cn() 유틸리티를 사용한다. CVA가 반환한 클래스와 외부에서 넘긴 className을 합칠 때 단순 문자열 결합이 아니라 cn()을 쓴다. 이 함수는 보통 clsxtailwind-merge를 조합한 것으로, Tailwind 클래스 충돌을 해결해준다. CVA 자체는 클래스 충돌 해결을 하지 않기 때문에 tailwind-merge와 함께 쓰는 것이 사실상 필수다.

typescript
// lib/utils.ts
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}

CVA가 하는 일과 하지 않는 일

CVA가 하는 일을 정확히 이해하면 다른 도구와의 관계가 명확해진다.

CVA가 하는 일:

  • Variant 정의를 받아 클래스 문자열을 반환하는 함수 생성
  • Compound variant 조건 매칭
  • TypeScript 타입 자동 추출
  • 기본값 처리

CVA가 하지 않는 일:

  • 클래스 충돌 해결 → tailwind-merge가 담당
  • 조건부 클래스 합성 → clsx가 담당
  • 실제 CSS 생성 → Tailwind나 CSS 파일이 담당
  • 스타일 캐싱이나 최적화 → 런타임 오버헤드가 거의 없어서 불필요

CVA는 순수한 매핑 함수다. CSS를 생성하지도, DOM을 조작하지도 않는다. 번들 사이즈도 1KB 미만으로 아주 작다. 결국 CVA, clsx, tailwind-merge 세 도구가 각자의 역할을 나누어 담당하는 구조다.


복잡한 컴포넌트에서의 활용

하나의 컴포넌트에 여러 부분이 있을 때는 각 부분에 대해 별도의 cva를 정의할 수 있다.

typescript
const inputWrapper = cva("flex items-center border rounded-md", {
  variants: {
    state: {
      default: "border-gray-300",
      focused: "border-blue-500 ring-2 ring-blue-200",
      error: "border-red-500 ring-2 ring-red-200",
    },
    size: {
      small: "h-8 px-2",
      medium: "h-10 px-3",
    },
  },
  defaultVariants: {
    state: "default",
    size: "medium",
  },
});

const inputLabel = cva("block mb-1 font-medium", {
  variants: {
    size: {
      small: "text-xs",
      medium: "text-sm",
    },
    state: {
      default: "text-gray-700",
      focused: "text-blue-600",
      error: "text-red-600",
    },
  },
  defaultVariants: {
    size: "medium",
    state: "default",
  },
});

이렇게 하면 같은 sizestate 값을 두 cva 함수에 넘겨서 일관된 스타일을 유지할 수 있다. 디자인 시스템의 여러 레이어(wrapper, label, input, helper text)를 체계적으로 관리하는 데 효과적이다.


Stitches와의 비교

CVA는 Stitches의 variants API에서 영감을 받았다. 둘의 차이를 비교하면 CVA의 위치가 명확해진다.

특성StitchesCVA
스타일 정의CSS 속성 객체클래스 문자열
CSS 생성런타임에 자동 생성하지 않음 (Tailwind 등에 위임)
번들 사이즈~6KB~1KB
CSS 프레임워크자체 내장아무거나 사용 가능
SSR 지원스타일 추출 필요추가 설정 불필요
TypeScript지원지원

Stitches는 스타일 정의부터 CSS 생성까지 전부 처리하는 통합 솔루션이고, CVA는 "클래스 이름 결정"이라는 한 가지 일만 한다. Tailwind를 쓰고 있다면 CSS 생성은 Tailwind가 이미 하고 있으니까 CVA만 있으면 된다.


실전 팁

variant 파일 분리

컴포넌트 파일이 커지면 variant 정의만 별도 파일로 분리할 수 있다.

text
components/
  button/
    index.tsx         # 컴포넌트
    variants.ts       # cva 정의
typescript
// components/button/variants.ts
export const buttonVariants = cva(/* ... */);
export type ButtonVariants = VariantProps<typeof buttonVariants>;

class 배열 vs 문자열

클래스가 3개 이하면 문자열이 깔끔하고, 그 이상이면 배열이 가독성이 좋다.

typescript
// 짧은 경우 - 문자열
intent: {
  primary: "bg-blue-500 text-white",
}

// 긴 경우 - 배열
intent: {
  primary: [
    "bg-blue-500",
    "text-white",
    "border-transparent",
    "shadow-sm",
    "hover:bg-blue-600",
    "focus-visible:outline-blue-500",
  ],
}

null로 "스타일 없음" 표현

특정 변형 값에서 아무 클래스도 추가하지 않으려면 null을 사용한다. 빈 문자열("")보다 의도가 명확하다.

typescript
variants: {
  shadow: {
    none: null,
    small: "shadow-sm",
    medium: "shadow-md",
    large: "shadow-lg",
  },
},

정리

  • CVA는 variant 조합 → 클래스 문자열 매핑을 선언적으로 정의하는 도구다. CSS를 생성하지 않고, 클래스 이름만 결정한다.
  • VariantProps로 타입을 자동 추출할 수 있어서 variant 정의와 props 타입이 항상 동기화된다.
  • Tailwind 프로젝트에서는 cn() (clsx + tailwind-merge)과 함께 쓰는 것이 사실상 필수다.

관련 문서