04 Compound Variants & State — 다축 조합 규칙과 data-state 통합
이 문서가 답하는 질문:
variant × size의 조합 전용 스타일을 어떻게 명시하고, 어느 규칙이 이기는가. 그리고 hover/focus 같은 상호작용 상태를 variant 시스템에 어떻게 끌어들이는가. 한 줄 답 (Pyramid Top): compoundVariants는 “잘못된 조합 차단 + 조합 전용 스타일”의 두 가지 책임을 동시에 진다. 그리고 상호작용 상태는 CSS pseudo(:hover,:focus)와data-state속성으로 끌어들여, 상태마저 variant 객체 안에서 일관성 있게 표현한다.
Why — 단순 variants만으로는 부족한 경우
variants 객체가 축별 독립이라는 것은 강력하지만, 실제 디자인에서는 축이 서로 간섭한다.
| 상황 | 단순 variants로는 | 필요한 것 |
|---|---|---|
| ”primary + lg일 때만 폰트 굵게” | 표현 불가 (size에 박으면 ghost도 굵게 됨) | compoundVariants |
| ”tone=outline일 때 모든 variant의 border 색이 다름” | 6개 variant × 2 tone = 12개 항목 | compoundVariants로 2축 결합 |
| ”disabled일 때는 hover 스타일 무효화” | CSS pseudo로 직접 | _disabled + state variant |
| ”Radix Popover가 열렸을 때만 outline” | React state로 className | data-state="open" + variants |
이 네 가지를 다 variants 객체 안에서 표현하는 것이 이 문서의 목표.
How — compoundVariants의 매칭 메커니즘
import { cva } from 'class-variance-authority'
const button = cva('inline-flex items-center rounded font-medium', {
variants: {
variant: {
primary: 'bg-blue-600 text-white',
ghost: 'bg-transparent text-blue-600',
danger: 'bg-red-600 text-white',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4',
lg: 'h-12 px-6 text-lg',
},
tone: {
solid: '',
outline: 'border-2 bg-transparent',
},
},
compoundVariants: [
// ① "primary + lg" 조합 — 폰트 굵게
{
variant: 'primary',
size: 'lg',
class: 'font-bold tracking-wide',
},
// ② "primary + outline" — 색을 명시
{
variant: 'primary',
tone: 'outline',
class: 'border-blue-600 text-blue-600 hover:bg-blue-50',
},
// ③ "danger + outline"
{
variant: 'danger',
tone: 'outline',
class: 'border-red-600 text-red-600 hover:bg-red-50',
},
// ④ 3축 조합 — "primary + outline + lg" 일 때만
{
variant: 'primary',
tone: 'outline',
size: 'lg',
class: 'border-4', // 더 굵은 border
},
],
defaultVariants: { variant: 'primary', size: 'md', tone: 'solid' },
})매칭 우선순위 (3가지 규칙)
- AND 매칭: 한 항목의 모든 키가 동시에 일치해야 적용.
{ variant: 'primary', size: 'lg' }는 둘 다일 때만. - 배열 순서: 여러 항목이 동시에 매칭되면 배열의 뒤가 이긴다 (className 문자열 순서).
cn()/twMerge를 쓰면 Tailwind utility 충돌도 뒤가 이김. - base → variants → compound: 최종 className은 항상
base + variants + compoundVariants순서로 합쳐진다. compound는 덮어쓰기 역할.
What — 잘못된 조합을 차단하는 패턴
compoundVariants는 덮어쓰기뿐 아니라 허용된 조합 명시에도 쓰인다. TypeScript와 결합하면 컴파일 타임 차단도 가능하다.
패턴 A: 모든 조합 허용 + 의미 없는 조합은 compound로 무효화
const button = cva('...', {
variants: {
variant: { primary: '...', danger: '...' },
tone: { solid: '...', outline: '...', ghost: '...' },
},
compoundVariants: [
// ghost + outline은 시각적으로 의미 없음 → solid로 강제
{ tone: 'ghost', variant: 'danger', class: 'bg-red-50 text-red-700 border-0' },
],
})패턴 B: 타입 레벨에서 잘못된 조합 차단 (discriminated union)
type ButtonVariant =
| { variant: 'primary'; tone?: 'solid' | 'outline' }
| { variant: 'ghost' } // ghost는 tone을 받지 않음
| { variant: 'danger'; tone: 'solid' | 'outline' } // danger는 tone 필수
function Button(props: ButtonVariant & React.ButtonHTMLAttributes<HTMLButtonElement>) {
// ...
}
// 컴파일 에러
<Button variant="ghost" tone="outline" />
// ✅
<Button variant="primary" />
<Button variant="danger" tone="solid" />VariantProps만으로는 교차 조합 제약을 표현할 수 없으므로, 진짜 중요한 제약은 별도 타입으로 한 번 더 좁힌다.
How — data-state 속성으로 상호작용 상태를 variant에 끌어들이기
CSS pseudo(:hover, :focus)는 브라우저가 자동 적용하지만, 다음 두 경우는 별도 표현이 필요하다:
- JS state 기반 상태 — Popover가 열림, Combobox가 활성, Accordion이 펼쳐짐
- headless 라이브러리와의 결합 — Radix/Ark UI는 모든 상태를
data-state속성으로 노출
Radix와의 결합 예시
import * as Popover from '@radix-ui/react-popover'
import { tv } from 'tailwind-variants'
const trigger = tv({
base: 'rounded px-3 py-2 transition-colors',
variants: {
state: {
closed: 'bg-gray-100 text-gray-700',
open: 'bg-blue-100 text-blue-900 ring-2 ring-blue-500',
},
},
defaultVariants: { state: 'closed' },
})
function MyPopover() {
return (
<Popover.Root>
{/* Radix가 자동으로 data-state="open" | "closed" 부여 */}
<Popover.Trigger
className={cn(
trigger(),
'data-[state=open]:bg-blue-100 data-[state=open]:ring-2'
)}
>
Open
</Popover.Trigger>
<Popover.Content>Content</Popover.Content>
</Popover.Root>
)
}Tailwind의 data-[state=open]: 변형 prefix
Tailwind는 임의 data attribute를 variant prefix로 지원한다 — data-[state=open]:bg-blue-100 같은 형태. 이 덕분에 JS state가 만들어 둔 data-state 속성을 className에서 직접 조건부 스타일로 끌어 쓸 수 있다.
| 표기 | 의미 |
|---|---|
data-[state=open]:bg-blue-100 | data-state="open"일 때 bg 적용 |
data-[disabled]:opacity-50 | data-disabled 속성 존재 시 |
group-data-[state=open]:rotate-180 | 부모의 data-state="open"일 때 |
aria-[expanded=true]:bg-blue-50 | ARIA 속성 기반 |
Panda CSS의 동등 표현
import { cva } from 'styled-system/css'
const trigger = cva({
base: { borderRadius: 'md', px: '3', py: '2' },
variants: {},
// Panda는 _state 패턴 + selectors
})
// 또는 css에서 직접
import { css } from 'styled-system/css'
<Trigger className={css({
bg: 'gray.100',
'&[data-state=open]': { bg: 'blue.100', ringWidth: '2' },
})} />Panda는 conditional selector를 CSS 객체 내부에서 자연스럽게 표현한다.
What — 상태 표현 4가지 패턴
| 패턴 | 누가 만드는가 | 표기 | 예시 |
|---|---|---|---|
| CSS pseudo | 브라우저 | :hover, :focus | utility의 hover:, focus: |
data-state 속성 | headless lib (Radix/Ark) | data-state="open" | data-[state=open]: |
| ARIA 속성 | 접근성 표준 | aria-expanded="true" | aria-[expanded=true]: |
| variants 분기 | 우리 코드 (React state) | <X state="open"> | state: { open, closed } |
권장 우선순위:
- 브라우저가 자동 부여하는 것은 pseudo로 (
hover,focus,disabled) - headless 라이브러리를 쓰면
data-state변형이 가장 일관성 - 우리 코드가 직접 만드는 상태만 variants 축으로 노출
이렇게 분리하면 같은 상태를 두 곳에서 표현하는 drift가 줄어든다.
What-if — 조합과 상태에서 깨지는 경우
-
함정 1: compoundVariants의 키 누락
// ❌ size 매칭만 — variant 무관하게 모든 size=lg에 적용됨 compoundVariants: [{ size: 'lg', class: 'font-bold' }] // → 이건 사실 variants.size.lg에 직접 박는 게 옳다 -
함정 2: 두 compound가 같은 조합을 다르게 정의 → 마지막 정의가 이김. 의도와 다르면 순서를 명시적으로 정렬해야
-
함정 3:
data-[state=open]:클래스를 동적 문자열로 — Tailwind JIT가 못 잡음// ❌ const cls = `data-[state=${value}]:bg-blue-100` // ✅ 명시 const cls = isOpen ? 'data-[state=open]:bg-blue-100' : '' // ✅ 또는 모든 가능한 값을 utility에 등장시켜 JIT가 보게 -
함정 4: ARIA와
data-state를 둘 다 같은 상태에 — Radix가 이미 둘 다 부여하므로 어느 한쪽만 variant로 쓰기. drift 방지 -
함정 5: defaultVariants에 상태 값을 박음 → SSR에서 항상 closed로 렌더 후 hydration에서 open으로 점프.
data-state만 쓰고 variants 축으로 꺼내지 않는 게 안전
Insight — data-state가 사실상 표준이 된 이유
2020년대 초까지는 상태별 className을 React state로 매번 분기하는 게 일반적이었다 ({isOpen ? 'open' : ''}). 그러나 이 방법은 두 문제가 있다:
- CSS만으로 디버깅 불가 — 브라우저 inspector에서 상태를 강제로 토글할 수가 없다
- 테스트 표면이 className — 깨지기 쉬움. CI에서
expect(getByRole('button').className).toContain('open')은 내부 표현에 결합
**Radix UI(2021)**는 이를 모든 인터랙티브 컴포넌트는 data-state 속성을 노출한다는 정책으로 풀었다. 그 결과:
- 디버깅: DevTools에서
data-state값을 직접 바꿔 시각 변화 확인 - 테스트:
expect(button).toHaveAttribute('data-state', 'open')— 안정적 - 스타일링:
data-[state=open]:한 줄로 CSS 분기
그 직후 Tailwind v3(2022)이 임의 data variant prefix를 1급 시민으로 도입하면서 두 시스템이 손 잡았다. 이 합의 덕분에 우리는 더 이상 React state로 className을 분기하지 않고, headless 라이브러리에 상태 관리를 위임하고 className은 data-attr만 본다는 분업이 가능해졌다.
결론적 인사이트: compoundVariants가 디자인 의사결정 조합의 직렬화라면,
data-state는 런타임 상태의 직렬화다. 둘이 만나는 지점에서 variant 시스템은 비로소 완전한 표현력을 갖는다.
요약
- compoundVariants는 AND 매칭 + 배열 순서로 동작. 잘못된 조합 차단과 다축 스타일에 둘 다 쓰임
- 진짜 중요한 조합 제약은 TypeScript discriminated union으로 한 번 더 좁혀라
- 상태는 4가지 경로 — pseudo /
data-state/ ARIA / variants 축. 원천이 같은 상태를 두 곳에 표현하지 말 것 - Radix + Tailwind
data-[state=open]:이 사실상 표준 — headless에 상태를, utility에 스타일을