tailwind-variants로 Button·Card 컴포넌트의 variant 체계를 선언적으로 구축하는 법 — Slots, extend, compoundVariants 활용
이 글은 Tailwind CSS를 이미 쓰고 있고, 컴포넌트 variant 관리에 불편함을 느끼는 프론트엔드 개발자를 위한 글입니다.
Tailwind를 쓰다 보면 어느 순간 반드시 마주치는 문제가 있습니다. 버튼 하나를 만들었는데, primary 색상에 lg 사이즈에 disabled 상태가 겹치면 어떤 클래스를 써야 하는지 if와 삼항 연산자가 뒤엉키기 시작하는 거죠. 저도 처음엔 clsx로 조건부 클래스를 관리하면 되겠지 싶었는데, 컴포넌트 수가 늘어날수록 "이게 맞나?" 싶은 순간이 오더라고요. CSS-in-JS를 경험해보신 분이라면 Stitches의 variant 개념이 딱 그 불편함을 해소해줬던 기억이 있을 텐데, tailwind-variants가 바로 그 철학을 Tailwind 세계로 가져온 라이브러리입니다.
이 글에서는 tv() 함수의 핵심 구조부터 Slots, extend, compoundVariants까지, Button·Card·IconButton 컴포넌트를 직접 만들며 Tailwind CSS 컴포넌트 스타일 관리 체계를 구축하는 과정을 살펴봅니다. 가장 큰 트레이드오프를 미리 말씀드리자면, Tailwind에 의존적인 라이브러리라 Tailwind를 쓰지 않는 프로젝트에는 맞지 않습니다. 반대로 Tailwind를 이미 쓰고 있다면, 클래스 충돌 자동 해결과 TypeScript 타입 추론이 즉시 붙어오는 덕분에 도입 비용 대비 체감이 꽤 좋은 편입니다. 2025년 기준 주당 약 148만 회 다운로드되고 HeroUI(구 NextUI) 전체 컴포넌트 시스템의 뼈대가 될 만큼 실전 검증이 된 라이브러리이기도 합니다.
핵심 개념
tv() 함수 하나로 컴포넌트의 스타일 전체를 선언한다
tailwind-variants의 진입점은 tv() 함수 하나입니다. 이 안에 네 가지 구조를 선언하는데, 처음 보면 낯설 수 있지만 각각의 역할이 분명해서 금방 익숙해집니다.
import { tv } from "tailwind-variants";
const button = tv({
// 1. base: 어떤 variant든 항상 적용되는 공통 클래스
base: "font-semibold rounded-full px-4 py-1 text-sm active:opacity-80",
// 2. variants: 각 variant 키와 선택 가능한 값들
variants: {
color: {
primary: "bg-blue-500 text-white hover:bg-blue-700",
secondary: "bg-purple-500 text-white hover:bg-purple-700",
ghost: "bg-transparent text-gray-700 border border-gray-300",
},
size: {
sm: "text-xs px-2 py-0.5",
md: "text-sm px-4 py-1",
lg: "text-base px-6 py-2",
},
// boolean variant: 키를 true/false로 선언하면 논리값으로 제어됩니다
disabled: {
true: "opacity-50 pointer-events-none",
},
},
// 3. compoundVariants: 특정 variant 조합일 때만 적용
compoundVariants: [
{
color: "primary",
disabled: true,
class: "bg-blue-200",
},
],
// 4. defaultVariants: 기본값 지정
defaultVariants: {
color: "primary",
size: "md",
},
});
// 클래스 문자열을 반환합니다
button({ color: "secondary", size: "lg" });tailwind-merge 내장:
tailwind-variants는 내부적으로tailwind-merge를 포함하고 있어,p-2와px-4처럼 같은 속성을 제어하는 클래스가 충돌할 때 후자를 우선 적용합니다. 별도 설정 없이 자동으로 처리됩니다.
TypeScript를 쓴다면 variant 키와 값 전체에 타입 추론이 붙습니다. 존재하지 않는 variant 값을 넘기면 컴파일 에러가 나기 때문에, 컴포넌트 API가 코드 레벨에서 자연스럽게 문서화됩니다.
Card처럼 header·body·footer로 구성된 복합 컴포넌트는 slots 옵션으로 각 파트를 선언합니다. 구체적인 사용 방법은 아래 예시 1에서 바로 살펴봅니다.
실전 적용
예시 1: Card 컴포넌트 — Slots + compoundVariants 조합
카드 컴포넌트는 elevated냐 outlined냐에 따라 전체 스타일이 달라지고, size에 따라 내부 텍스트 크기와 패딩도 함께 바뀌어야 합니다. 이런 복합 컴포넌트에서 slots의 역할이 명확하게 드러납니다.
import { tv, type VariantProps } from "tailwind-variants";
const card = tv({
slots: {
root: "rounded-xl border bg-white overflow-hidden transition-shadow",
header: "px-6 py-4 border-b font-semibold text-gray-900",
body: "px-6 py-4 text-gray-700",
footer: "px-6 py-4 border-t bg-gray-50",
},
variants: {
size: {
sm: { root: "max-w-sm", header: "text-sm py-3", body: "text-xs" },
md: { root: "max-w-md", header: "text-base", body: "text-sm" },
lg: { root: "max-w-lg", header: "text-lg py-5", body: "text-base" },
},
variant: {
elevated: { root: "shadow-md hover:shadow-xl" },
outlined: { root: "border-2 border-blue-200 shadow-none" },
flat: { root: "shadow-none border-transparent bg-gray-50" },
},
},
// elevated + lg 조합일 때만 헤더에 추가 강조 스타일
compoundVariants: [
{
variant: "elevated",
size: "lg",
class: { header: "text-xl font-bold" },
},
],
defaultVariants: {
size: "md",
variant: "elevated",
},
});
// tv()의 반환 타입에서 variant props 타입을 추출합니다
type CardVariants = VariantProps<typeof card>;
type CardProps = CardVariants & {
title: string;
children: React.ReactNode;
footerContent?: React.ReactNode;
className?: string;
};
function Card({ size, variant, title, children, footerContent, className }: CardProps) {
const { root, header, body, footer } = card({ size, variant });
return (
// 슬롯 함수 호출 시 추가 클래스를 인자로 넘길 수 있습니다
<div className={root({ class: className })}>
<div className={header()}>{title}</div>
<div className={body()}>{children}</div>
{footerContent && <div className={footer()}>{footerContent}</div>}
</div>
);
}처음 이 패턴을 쓸 때 prop 이름 footer와 slots에서 구조분해된 footer가 이름 충돌을 일으킬 것 같아서 footerSlot으로 바꿔 쓴 적이 있었는데, 나중에 보니 prop 이름을 footerContent로 처음부터 정하면 충돌 자체가 생기지 않아서 코드가 훨씬 자연스럽습니다. 작은 부분이지만 실수하기 쉬운 지점이더라고요.
| 구성 요소 | 역할 |
|---|---|
slots |
컴포넌트 각 파트(root, header, body, footer)의 기본 클래스 정의 |
variants.size |
사이즈에 따라 각 파트의 텍스트 크기·패딩이 함께 바뀌는 규칙 선언 |
variants.variant |
카드의 시각적 스타일(그림자, 테두리) 분기 |
compoundVariants |
elevated + lg 조합이라는 특수 케이스만 별도 처리 |
root({ class: className }) |
슬롯 함수 호출 시 외부에서 추가 클래스를 주입하는 패턴 |
Slots의 핵심 가치: 여러 하위 요소로 구성된 컴포넌트에서 "이 variant일 때 root는 이렇고, header는 이렇고..." 라는 관계를 하나의 진실의 원천(single source of truth)으로 유지할 수 있습니다. 파트를 별도
tv()로 쪼개면 이 연동이 깨집니다.
예시 2: Button → IconButton — extend로 계층 구조 만들기
디자인 시스템을 만들다 보면 Base 컴포넌트에서 Specific 컴포넌트로 확장하는 패턴이 자주 필요합니다. extend를 쓰면 기존 variant를 모두 상속하면서 새 variant를 추가할 수 있습니다.
import { tv, type VariantProps } from "tailwind-variants";
// Base: 모든 버튼 공통 스타일
const button = tv({
base: "font-semibold rounded-lg px-4 py-2 transition-colors focus-visible:outline-none focus-visible:ring-2",
variants: {
color: {
primary: "bg-blue-500 text-white hover:bg-blue-600",
danger: "bg-red-500 text-white hover:bg-red-600",
ghost: "bg-transparent text-gray-700 border border-gray-300 hover:bg-gray-50",
},
size: {
sm: "text-xs px-3 py-1.5",
md: "text-sm px-4 py-2",
lg: "text-base px-6 py-3",
},
// boolean variant: disabled 상태를 true 키로 제어합니다
disabled: {
true: "opacity-50 pointer-events-none cursor-not-allowed",
},
},
defaultVariants: { color: "primary", size: "md" },
});
// IconButton: button을 확장 — color, size, disabled는 모두 상속됩니다
const iconButton = tv({
extend: button,
base: "inline-flex items-center gap-2",
variants: {
iconPosition: {
left: "flex-row",
right: "flex-row-reverse",
},
// boolean variant: 아이콘만 있을 때 패딩을 재정의합니다
iconOnly: {
true: "px-2 aspect-square",
},
},
});
type ButtonProps = VariantProps<typeof button>;
type IconButtonProps = VariantProps<typeof iconButton>;
// button의 color, size와 iconButton의 iconPosition 모두 사용 가능합니다
iconButton({ color: "primary", size: "lg", iconPosition: "left" });
iconButton({ color: "danger", iconOnly: true, size: "sm" });
extend의 동작: 부모tv()선언의base,variants,compoundVariants를 모두 상속하고, 자식에서 선언한 것들이 병합됩니다. 같은 키를 재선언하면 자식 값이 우선합니다.
조금 더 나아가서: 반응형 Variant
다음 두 예시는 앞의 Button·Card보다 한 단계 더 나아간 패턴입니다. 기본 사용에는 필수가 아니지만, 프로젝트 규모가 커지면 자연스럽게 필요해지는 기능들입니다.
import { createTV } from "tailwind-variants";
// 반응형 variant 객체 문법을 사용하려면 createTV로 옵션을 활성화해야 합니다
const tv = createTV({
responsiveVariants: true,
});
const grid = tv({
base: "grid gap-4",
variants: {
cols: {
1: "grid-cols-1",
2: "grid-cols-2",
3: "grid-cols-3",
4: "grid-cols-4",
},
},
});
// 반응형: 모바일 1열 → 태블릿 2열 → 데스크톱 3열
grid({ cols: { initial: "1", sm: "1", md: "2", lg: "3" } });
// → "grid gap-4 grid-cols-1 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3"주의:
cols: { initial: "1", md: "2" }형태의 반응형 객체 문법은responsiveVariants옵션을 활성화해야만 사용할 수 있습니다. 일반tv()에서 옵션 없이 객체를 넘기면 의도한 대로 동작하지 않습니다.
조금 더 나아가서: Tailwind v4 디자인 토큰과 연결하기
Tailwind CSS v4에서는 @theme 블록으로 CSS 변수 기반 디자인 토큰을 선언합니다. tailwind-variants의 variant 클래스 안에서 이 시맨틱 토큰을 직접 참조하면 토큰 계층 구조가 코드와 연결됩니다.
/* globals.css — Tailwind v4 */
@import "tailwindcss";
@theme {
/* Base 토큰: 원시값 */
--color-blue-500: #3b82f6;
/* Semantic 토큰: 목적 기반 */
--color-brand-primary: var(--color-blue-500);
--color-brand-danger: var(--color-red-500);
}const button = tv({
base: "font-semibold rounded-lg px-4 py-2",
variants: {
intent: {
// CSS 변수 기반 임의값(arbitrary value) 문법으로 시맨틱 토큰을 참조합니다
brand: "bg-[--color-brand-primary] text-white",
danger: "bg-[--color-brand-danger] text-white",
},
},
});v4 문법 참고: Tailwind CSS v4에서는 CSS 변수 참조 시
bg-(--var-name)형태의 괄호 문법이 새로 추가되었습니다.bg-[--var-name]대괄호 문법도 동작하지만, 사용 중인 Tailwind 버전에 맞는 문법을 공식 문서에서 확인 후 적용하는 것을 권장합니다.
| 계층 | 도구 | 예시 |
|---|---|---|
| Base 토큰 | CSS 변수 (@theme) |
--color-blue-500: #3b82f6 |
| Semantic 토큰 | CSS 변수 참조 | --color-brand-primary: var(--color-blue-500) |
| Component variant | tailwind-variants |
"bg-[--color-brand-primary]" |
이 구조는 Figma 토큰 → CSS 변수 → 컴포넌트 variant로 이어지는 파이프라인을 코드 수준에서 체계화하는 패턴으로, 디자인 토큰 하나를 변경했을 때 연결된 컴포넌트 전체에 반영되는 흐름이 자연스럽게 만들어집니다.
장단점 분석
솔직히 말하면 tailwind-variants가 모든 프로젝트에 맞는 도구는 아닙니다. 아래 표를 참고해서 현재 상황에 맞는지 판단해보시면 좋습니다.
장점
| 항목 | 내용 |
|---|---|
| TypeScript 완전 지원 | variant 키·값 전체에 타입 추론, VariantProps로 컴포넌트 타입을 자동 추출 |
| tailwind-merge 내장 | 클래스 충돌 자동 해결, 별도 설정 없음 |
| Slots | 복합 컴포넌트 전체를 하나의 tv() 선언으로 관리, 파트 간 variant 연동 유지 |
| extend | Base → Specific 컴포넌트 계층 구성, variant 상속 |
| compoundVariants | 특정 variant 조합에만 적용되는 스타일로 복잡한 상태 표현 |
| 프레임워크 무관 | React, Vue, Svelte, Solid 등 어느 환경에서도 동작 |
| 런타임 오버헤드 없음 | 클래스 문자열만 생성, 런타임 CSS 주입 없음 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| TailwindCSS 필수 의존성 | 라이브러리로 배포 시 소비자 프로젝트에도 Tailwind 설정 필요 | Tailwind 사용 프로젝트 전용으로만 채택, CVA 대안 검토 |
| 초기 설계 비용 | slots, compoundVariants, extend 조합은 설계 복잡도 증가 | HeroUI 오픈소스 코드를 레퍼런스로 참고하면 학습 비용 절감 |
| 클래스 문자열 장황함 | 생성되는 HTML 클래스 문자열이 길어 디버깅 시 가독성 저하 | 브라우저 DevTools에서 computed 스타일 위주로 확인 |
| 동적 클래스 퍼지 문제 | 동적으로 생성되는 클래스는 Tailwind purge에서 제거될 수 있음 | safelist 설정 또는 variant 값을 완전한 클래스명으로 선언 |
Purge(PurgeCSS): Tailwind는 빌드 시 실제로 사용된 클래스만 남기고 나머지를 제거합니다.
"bg-" + color같은 방식으로 클래스를 동적 조합하면 빌드 결과물에서 해당 클래스가 사라질 수 있습니다. tailwind-variants를 쓸 때는"bg-blue-500"형태의 완전한 클래스명을 variant 값으로 직접 작성하는 것이 안전합니다.
실무에서 가장 흔한 실수
- variant 값에 클래스를 동적 조합하는 경우 —
"bg-" + colorName같은 패턴은 Tailwind purge에서 제거됩니다."bg-blue-500"형태의 완전한 클래스명을 variant 값으로 직접 작성하는 것을 권장합니다. - compoundVariants을 남용하는 경우 — 단순한 조합도 모두 compoundVariants로 넣으면 선언이 길어지고 추적이 어려워집니다. "두 variant 값이 동시에 적용될 때만 의미가 생기는 스타일"에만 사용하는 것이 좋습니다.
- slots의 각 파트에 독립적인
tv()를 선언하는 경우 — 파트를 별도tv()로 쪼개면 variant 값 변화에 따른 파트 간 스타일 연동이 불가능해집니다. 하나의 복합 컴포넌트는 하나의tv()선언 안에 slots로 담는 것이 맞습니다.
마치며
tailwind-variants는 Tailwind 유틸리티 클래스의 유연함을 유지하면서, 컴포넌트가 가져야 할 variant 체계의 선언성과 타입 안전성을 함께 제공합니다.
도입을 고려하고 있다면 이렇게 접근해보시면 좋습니다:
- 현재 프로젝트에서
clsx나 삼항 연산자로 variant를 관리하는 버튼 컴포넌트를 하나 골라서pnpm add tailwind-variants로 설치한 뒤tv()로 옮겨볼 수 있습니다.VariantProps로 타입이 자동으로 따라오는 것을 확인하면 전체 흐름을 빠르게 파악할 수 있습니다. - 단일 요소 컴포넌트가 익숙해졌다면,
slots를 활용해 Card나 Modal처럼 여러 파트로 구성된 컴포넌트를 하나 만들어보시면 좋습니다. HeroUI의 오픈소스 코드(github.com/heroui-inc/heroui)는 대규모 설계에서 slots와 compoundVariants를 어떻게 조합하는지 구체적인 레퍼런스로 참고하기에 좋습니다. - 디자인 시스템을 구축 중이라면, Tailwind v4의
@theme블록에 시맨틱 토큰을 선언하고 variant 클래스에서 CSS 변수로 참조하는 3계층 토큰 아키텍처를 검토해볼 수 있습니다. Figma 변수 → CSS 변수 → variant 클래스로 이어지는 파이프라인이 정리되면, 디자인 토큰 변경이 컴포넌트 전체에 일관되게 반영되는 구조가 자연스럽게 만들어집니다.
참고 자료
- tailwind-variants 공식 문서 | tailwind-variants.org
- Variants API | tailwind-variants.org
- Slots | tailwind-variants.org
- Composing Components | tailwind-variants.org
- tailwind-variants GitHub | heroui-inc
- CVA vs. Tailwind Variants: Choosing the Right Tool for Your Design System | DEV Community
- Simplifying TailwindCSS with Tailwind Variants in React | DEV Community
- Scalable Tailwind Components: A How-To Guide | Hashnode
- Design Tokens That Scale in 2026 (Tailwind v4 + CSS Variables) | Mavik Labs
- HeroUI 공식 사이트
- tailwind-variants | npm