🧩 Design System5. Composition (Slot·Polymorphism)Styling Third-Party Components — data attribute로 입히는 우리 디자인

Styling Third-Party Components — data attribute로 입히는 우리 디자인

이 문서가 답하는 질문: Radix Dialog가 열렸을 때는 fade-in, 닫혔을 때는 fade-out. ARIA 비활성 상태, focused, hover, selected — 이 모든 상태에 우리 토큰으로 어떻게 스타일을 입히는가? 한 줄 답 (Pyramid Top): Headless 라이브러리들은 모두 data-state / data-disabled / data-orientation 같은 attribute로 상태를 노출한다. Panda는 _open: { ... }, Tailwind는 data-[state=open]:... selector로 그 attribute를 hook한다. CSS class state가 아니라 data attribute가 표준 인터페이스.


Why — 왜 className이 아니라 data attribute인가

옛 라이브러리들은 상태별 class를 줬다 — .is-open, .is-active, .has-error. 그 시대 문제:

문제예시
네이밍 충돌두 라이브러리가 모두 .is-active를 정의
CSS 특이도 전쟁라이브러리 class를 override하려면 !important
state 분리 안 됨.is-open.is-disabled 같은 조합 표현이 라이브러리마다 다름
우리 토큰 통합 어려움.is-open 안에 무엇이 있는지 모름, 강제로 덮어쓰기

data-* attribute는 이 문제들을 한 번에 푼다:

  • 네임스페이스 격리: data-state="open"은 attribute 이름이지 class가 아니다 — 충돌 없음.
  • machine-readable: ARIA·테스트·CSS 모두가 같은 source를 본다.
  • 자유 조합: data-state="open" + data-disabled는 그냥 두 selector를 chain.
  • 개입 지점 명확: 라이브러리가 우리에게 줄 것은 attribute뿐. 클래스는 0%.
풀려는 문제class statedata attribute
특이도.lib.is-open vs .my-override 다툼[data-state=open]은 attribute selector, 다툼 없음
테스트DOM 파싱 + class 검색screen.getByRole('dialog', { hidden: false }) + data-state
애니메이션unmount되면 closed 상태 못 보임Radix는 data-state="closed" 동안 DOM 유지

How — Panda vs Tailwind의 두 문법


What — 구체 사양 / 코드

Panda CSS — _open, _closed, _disabled

Panda는 자주 쓰는 data attribute selector를 condition으로 미리 정의해놨다.

// styled-system/recipes/dialog.ts
import { sva } from 'styled-system/css'
 
export const dialog = sva({
  slots: ['overlay', 'content', 'title'],
  base: {
    overlay: {
      position: 'fixed', inset: 0,
      bg: 'blackAlpha.500',
      _open: { animation: 'fadeIn 150ms ease-out' },
      _closed: { animation: 'fadeOut 150ms ease-in' },
    },
    content: {
      position: 'fixed',
      top: '50%', left: '50%',
      transform: 'translate(-50%, -50%)',
      bg: 'surface.default',
      borderRadius: 'lg',
      shadow: 'xl',
      _open: { animation: 'slideUpIn 200ms ease-out' },
      _closed: { animation: 'slideUpOut 200ms ease-in' },
    },
    title: { fontWeight: 'semibold', fontSize: 'lg' },
  },
})

Panda가 미리 정의하는 주요 conditions:

Condition실제 selector출처
_open&[data-state="open"]Radix, Headless UI
_closed&[data-state="closed"]Radix
_disabled&:disabled, &[data-disabled]네이티브 + Radix
_checked&:checked, &[data-state="checked"]둘 다
_selected&[aria-selected="true"]ARIA
_active&[data-state="active"]Tabs.Trigger 등
_hover&:hover네이티브
_focusVisible&:focus-visible네이티브
_horizontal&[data-orientation="horizontal"]Radix
_vertical&[data-orientation="vertical"]Radix

커스텀 condition도 정의 가능:

// panda.config.ts
export default defineConfig({
  conditions: {
    extend: {
      indeterminate: '&[data-indeterminate]',
      loading: '&[data-loading]',
    },
  },
})

→ 이후 _indeterminate: { ... } 사용 가능.

Tailwind v3+ — data-[attr=value]: modifier

<Dialog.Content
  className="
    fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
    bg-white rounded-lg shadow-xl
    data-[state=open]:animate-in
    data-[state=closed]:animate-out
    data-[state=closed]:fade-out
    data-[state=open]:fade-in
  "
>

Tailwind는 임의의 attribute selector를 modifier로 지원:

Modifier의미
data-[state=open]:bg-...[data-state="open"]일 때
data-[disabled]:opacity-50[data-disabled] 존재 시 (값 무관)
data-[orientation=vertical]:flex-col방향
aria-selected:bg-...ARIA attribute
aria-[invalid=true]:border-red-500ARIA 값 명시
peer-data-[state=open]:rotate-180sibling의 attribute로 (chevron 회전 등)

tailwindcss-animate + Radix 패턴

shadcn/ui는 tailwindcss-animate 플러그인과 결합한다:

<Dialog.Content
  className="
    data-[state=open]:animate-in
    data-[state=closed]:animate-out
    data-[state=closed]:fade-out-0
    data-[state=open]:fade-in-0
    data-[state=closed]:zoom-out-95
    data-[state=open]:zoom-in-95
  "
>

→ Radix가 unmount를 지연시켜 data-state="closed"인 동안 DOM에 남아있고, 그 사이에 animation이 실행된다.

Panda + Radix — 완전한 wrap 패턴

// packages/ds/src/select.tsx
import * as RadixSelect from '@radix-ui/react-select'
import { css, cx } from 'styled-system/css'
import { ChevronDownIcon, CheckIcon } from '@your-co/icons'
 
const triggerStyles = css({
  display: 'inline-flex',
  alignItems: 'center',
  gap: '2',
  px: '3', py: '2',
  borderRadius: 'md',
  borderWidth: '1px',
  borderColor: 'border.default',
  bg: 'surface.default',
  fontSize: 'sm',
  _hover: { bg: 'surface.muted' },
  _focusVisible: { ringColor: 'brand.500', ringWidth: '2px' },
  _disabled: { opacity: 0.5, cursor: 'not-allowed' },
  '&[data-placeholder]': { color: 'text.muted' },
})
 
const contentStyles = css({
  bg: 'surface.default',
  borderRadius: 'md',
  shadow: 'lg',
  borderWidth: '1px',
  borderColor: 'border.default',
  overflow: 'hidden',
  _open: { animation: 'fadeIn 100ms ease-out' },
  _closed: { animation: 'fadeOut 100ms ease-in' },
})
 
const itemStyles = css({
  px: '3', py: '2',
  fontSize: 'sm',
  cursor: 'pointer',
  outline: 'none',
  _highlighted: { bg: 'brand.100', color: 'brand.900' },  // Radix가 [data-highlighted]
  _disabled: { opacity: 0.5, pointerEvents: 'none' },
})
 
export const Select = {
  Root: RadixSelect.Root,
  Value: RadixSelect.Value,
 
  Trigger: forwardRef<HTMLButtonElement, RadixSelect.SelectTriggerProps>((props, ref) => (
    <RadixSelect.Trigger ref={ref} {...props} className={cx(triggerStyles, props.className)}>
      {props.children}
      <RadixSelect.Icon><ChevronDownIcon /></RadixSelect.Icon>
    </RadixSelect.Trigger>
  )),
 
  Content: forwardRef<HTMLDivElement, RadixSelect.SelectContentProps>((props, ref) => (
    <RadixSelect.Portal>
      <RadixSelect.Content ref={ref} {...props} className={cx(contentStyles, props.className)}>
        <RadixSelect.Viewport>{props.children}</RadixSelect.Viewport>
      </RadixSelect.Content>
    </RadixSelect.Portal>
  )),
 
  Item: forwardRef<HTMLDivElement, RadixSelect.SelectItemProps>((props, ref) => (
    <RadixSelect.Item ref={ref} {...props} className={cx(itemStyles, props.className)}>
      <RadixSelect.ItemText>{props.children}</RadixSelect.ItemText>
      <RadixSelect.ItemIndicator><CheckIcon /></RadixSelect.ItemIndicator>
    </RadixSelect.Item>
  )),
}

→ 앱 코드:

<Select.Root value={tier} onValueChange={setTier}>
  <Select.Trigger><Select.Value placeholder="플랜 선택" /></Select.Trigger>
  <Select.Content>
    <Select.Item value="free">Free</Select.Item>
    <Select.Item value="pro">Pro</Select.Item>
  </Select.Content>
</Select.Root>

data attribute reference (Radix Primitives)

Attribute등장 컴포넌트의미
data-state="open" | "closed"Dialog, Popover, Tooltip, DropdownMenu, Accordion, Collapsible열림/닫힘
data-state="active" | "inactive"Tabs.Trigger, ToggleGroup활성
data-state="checked" | "unchecked" | "indeterminate"Checkbox, Radio, Switch체크
data-disabled모든 interactive 컴포넌트비활성 (값 무관)
data-highlightedDropdownMenu.Item, Select.Item키보드/마우스 highlight
data-orientation="horizontal" | "vertical"Tabs, Slider, ToggleGroup, Separator방향
data-side="top" | "right" | "bottom" | "left"Popover, Tooltip, DropdownMenu포지셔닝
data-align="start" | "center" | "end"Popover, Tooltip정렬
data-placeholderSelect.Triggerplaceholder 표시 중

What-if — 잘못 쓰면 어떻게 깨지는가

  • 함정 1 — 자기 useState로 open 상태 추적해서 스타일링

    • 증상: <div className={open ? 'opacity-100' : 'opacity-0'}> → Radix의 애니메이션을 위한 closed 동안의 DOM 유지가 무용해짐.
    • 대응: data-state만 보고 스타일. 우리 state는 controlled prop에 넘기는 용도로만.
  • 함정 2 — class state로 override 시도

    • 증상: .radix-dialog-open { ... } 같은 임의 class 추적. Radix가 그런 class를 안 줘서 실패.
    • 대응: 모든 selector는 [data-state=...] 또는 [data-disabled]로.
  • 함정 3 — _disabled:disabled의 차이 무시

    • 증상: <button disabled>에는 :disabled가 붙지만, <div data-disabled>는 안 붙음. 둘 다 처리해야 모든 경우 커버.
    • 대응: Panda _disabled둘 다 처리하도록 미리 정의됨. 직접 selector 쓸 땐 &:disabled, &[data-disabled].
  • 함정 4 — animation이 라이브러리와 충돌

    • 증상: 자기 Framer Motion으로 fade-in 만들었는데 Radix가 unmount 안 함 → 두 transition이 충돌.
    • 대응: Radix Presence(forceMount) + Framer Motion의 AnimatePresence 패턴을 따라가거나, 그냥 data-state + CSS animation으로.
  • 함정 5 — Tailwind가 data 셀렉터를 모름 (v2 이하)

    • 증상: data-[state=open]:이 빌드에서 무시됨.
    • 대응: Tailwind v3+ 또는 tailwindcss-data 플러그인. 또는 그냥 [data-state=open]:addVariant로 등록.
  • 함정 6 — selector를 자식 element에 잘못 매핑

    • 증상: data-state는 root에 있는데 자식 className에서 data-[state=open]:bg-... 쓰면 동작 안 함.
    • 대응: root에 attribute가 있다면 root의 className에서 selector 쓰기. 또는 group-data-[state=open]: (peer/group 패턴).

Insight — data-*가 표준이 된 짧은 역사

data-* attribute는 HTML5(2008년)에서 도입되었지만, 상태 표현에 본격적으로 쓰이기 시작한 건 2020년 Radix Primitives부터다.

그 전에는 세 진영이 경쟁했다:

  • jQuery/Bootstrap: .is-open 같은 state class.
  • React Aria의 초기: render prop으로 {({ isOpen }) => ...} 처럼 함수 전달.
  • Headless UI: render prop과 attribute 혼용.

Radix가 attribute만 쓰는 방침을 택하자, CSS-first의 Tailwind와 자연스럽게 결혼했다. 2021~2022년 data-[state=open]: modifier가 Tailwind 공식에 들어가면서 Radix + Tailwind현대 React UI의 사실상 표준 스택이 되었다.

흥미로운 부작용 — 테스트 코드가 단순해졌다. expect(dialog).toHaveAttribute('data-state', 'open')이면 끝. 시각 상태 = attribute이므로 시각 회귀 테스트와 단위 테스트가 같은 selector로 작성 가능하다.


요약

  • Headless 라이브러리는 data attribute로 상태를 노출. class state 시대는 끝.
  • Panda: _open, _closed, _disabled, _highlighted 등 condition으로 hook.
  • Tailwind: data-[state=open]:, aria-selected: 등 modifier로 hook.
  • Radix의 closed 동안의 DOM 유지가 fade-out animation의 기반. 자기 state로 override 마라.
  • 디자인 시스템 패키지가 Radix를 wrap하면서 우리 토큰을 입힌다 — cx() / cn()으로 사용자 className 머지 잊지 말기.