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 state | data 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-500 | ARIA 값 명시 |
peer-data-[state=open]:rotate-180 | sibling의 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-highlighted | DropdownMenu.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-placeholder | Select.Trigger | placeholder 표시 중 |
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 머지 잊지 말기.