🧩 Design System4. Recipes & Variants04 Compound Variants & State — 다축 조합 규칙과 data-state 통합

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로 classNamedata-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가지 규칙)

  1. AND 매칭: 한 항목의 모든 키가 동시에 일치해야 적용. { variant: 'primary', size: 'lg' }둘 다일 때만.
  2. 배열 순서: 여러 항목이 동시에 매칭되면 배열의 뒤가 이긴다 (className 문자열 순서). cn()/twMerge를 쓰면 Tailwind utility 충돌도 뒤가 이김.
  3. 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)는 브라우저가 자동 적용하지만, 다음 두 경우는 별도 표현이 필요하다:

  1. JS state 기반 상태 — Popover가 열림, Combobox가 활성, Accordion이 펼쳐짐
  2. 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-100data-state="open"일 때 bg 적용
data-[disabled]:opacity-50data-disabled 속성 존재 시
group-data-[state=open]:rotate-180부모의 data-state="open"일 때
aria-[expanded=true]:bg-blue-50ARIA 속성 기반

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, :focusutility의 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 }

권장 우선순위:

  1. 브라우저가 자동 부여하는 것은 pseudo로 (hover, focus, disabled)
  2. headless 라이브러리를 쓰면 data-state 변형이 가장 일관성
  3. 우리 코드가 직접 만드는 상태만 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' : ''}). 그러나 이 방법은 두 문제가 있다:

  1. CSS만으로 디버깅 불가 — 브라우저 inspector에서 상태를 강제로 토글할 수가 없다
  2. 테스트 표면이 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에 스타일을