Headless UI Pattern — 동작은 우리가, 스타일은 너가
이 문서가 답하는 질문: Dialog의 focus trap, Combobox의 키보드 내비, Menu의 ARIA를 직접 만들지 않고 디자인만 책임지려면? 한 줄 답 (Pyramid Top): Headless 라이브러리는 마크업·동작·접근성을 unstyled 컴포넌트로 제공하고, 우리는 클래스만 입힌다. Radix Primitives, Headless UI, React Aria, Ariakit, Reka UI(Vue), Melt UI(Svelte) — 동작은 너무 어렵고, 디자인은 너무 다양해서 둘을 갈라놓는 게 사실상 표준이 되었다.
Why — 왜 동작과 외형을 분리하는가
다음을 직접 구현한다고 상상해보자. Dialog 하나만:
| 책임 | 디테일 |
|---|---|
| Focus trap | Dialog 열리면 안으로 focus 이동, Tab이 밖으로 못 나감, 닫히면 trigger로 복귀 |
| Outside click | Overlay 클릭 시 닫힘 (단, 드래그로 시작한 클릭은 제외) |
| Escape key | ESC로 닫힘 |
| Inert background | 뒤의 콘텐츠가 스크린리더에 안 읽힘 (aria-hidden, inert attr) |
| Scroll lock | 배경 스크롤 방지 (iOS는 다른 트릭 필요) |
| ARIA | role="dialog", aria-modal, aria-labelledby, aria-describedby |
| Mount/unmount 애니메이션 | 닫힘 애니메이션이 끝나야 DOM에서 제거 |
| Portal | body 최하단에 렌더 (z-index, 스택 컨텍스트 회피) |
| SSR | hydration 깨짐 방지, controlled state 유지 |
하나의 컴포넌트에 이 모든 게 들어간다. Dialog만이 아니다. Menu, Combobox, Listbox, DatePicker, Toast, Toolbar, Tooltip — 다 비슷한 깊이다.
| 풀려는 문제 | 이전의 해법 | 한계 |
|---|---|---|
| 복잡한 ARIA·키보드·focus 동작 | 회사마다 직접 작성 | 매번 버그, 접근성 검증 안 됨 |
| Bootstrap·MUI 같은 styled 라이브러리 | 디자인이 고정 | 우리 토큰을 입히기 어려움 |
| 회사 디자인 시스템과 통합 | styled 라이브러리를 override | CSS 특이도 전쟁 |
Headless 라이브러리의 약속:
“버튼 5px만한 외형은 너희가 그리되, 그 안의 복잡한 동작은 우리가 책임진다.”
How — 동작-외형 분리의 구조
핵심 메커니즘: Headless 라이브러리는 자식 element에 data attribute를 부여한다 — data-state="open", data-disabled, data-orientation="vertical". 우리는 그 attribute에 반응하는 CSS만 작성하면 된다.
What — 6개 라이브러리 지형도
| 라이브러리 | 프레임워크 | 스타일 | 특징 |
|---|---|---|---|
| Radix Primitives | React | unstyled | 사실상 표준. shadcn/ui의 토대. asChild 패턴 발원지 |
| Headless UI | React, Vue | unstyled | Tailwind 팀 작품. Listbox/Combobox/Menu/Dialog/Transition 등 |
| React Aria (Adobe) | React | hook-based | 어댑티브 입력 (마우스/터치/키보드/펜) 가장 정교 |
| Ariakit | React | unstyled | React Aria보다 가볍고, Radix보다 컴포넌트 다양 |
| Reka UI (구 Radix Vue) | Vue | unstyled | Radix Primitives의 Vue 포팅 |
| Melt UI | Svelte | builder-based | Svelte 컨벤션에 맞춘 store/action 패턴 |
Radix Primitives 예시
import * as Dialog from '@radix-ui/react-dialog'
export function MyDialog() {
return (
<Dialog.Root>
<Dialog.Trigger asChild>
<Button>Open</Button>
</Dialog.Trigger>
<Dialog.Portal>
<Dialog.Overlay className={dialog.overlay} />
<Dialog.Content className={dialog.content}>
<Dialog.Title>제목</Dialog.Title>
<Dialog.Description>설명</Dialog.Description>
<Dialog.Close asChild><Button>닫기</Button></Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}→ Radix는 동작 + 마크업 + data attribute를 책임. 우리는 dialog.overlay, dialog.content의 클래스 (Panda slot recipe 또는 Tailwind utility)만 정의.
Headless UI (Tailwind 팀) 예시
import { Listbox } from '@headlessui/react'
<Listbox value={selected} onChange={setSelected}>
<Listbox.Button className="...">{selected.name}</Listbox.Button>
<Listbox.Options className="...">
{people.map(p => (
<Listbox.Option
key={p.id}
value={p}
className={({ active, selected }) =>
`${active ? 'bg-brand-100' : ''} ${selected ? 'font-semibold' : ''}`
}
>
{p.name}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>→ Headless UI는 className에 render prop 함수도 허용 — { active, selected } 같은 상태를 함수로 받는다. Radix가 data attribute로 푸는 문제를 함수로 푼다는 차이.
React Aria (Adobe) — hook 스타일
import { useButton } from 'react-aria'
import { useRef } from 'react'
export function Button(props) {
const ref = useRef(null)
const { buttonProps, isPressed } = useButton(props, ref)
return (
<button {...buttonProps} ref={ref} data-pressed={isPressed}>
{props.children}
</button>
)
}→ React Aria는 컴포넌트가 아니라 hook을 준다. 가장 유연하지만 코드량도 많다. Adobe Spectrum의 토대.
Ariakit 예시
import * as Ariakit from '@ariakit/react'
const combobox = Ariakit.useComboboxStore()
<Ariakit.Combobox store={combobox} />
<Ariakit.ComboboxPopover store={combobox}>
<Ariakit.ComboboxItem value="Apple" />
<Ariakit.ComboboxItem value="Banana" />
</Ariakit.ComboboxPopover>→ Ariakit은 store 개념을 외부에 노출 — Radix보다 low-level 컨트롤이 쉽다. Combobox, Menu, Tab, Dialog 등 컴포넌트 다양성은 Radix보다 넓다.
우리 디자인 시스템에서 wrapping
Headless를 직접 import하지 않고, 디자인 시스템 패키지가 한 번 wrap한다:
// packages/ds/src/dialog.tsx
import * as RadixDialog from '@radix-ui/react-dialog'
import { dialog as dialogRecipe } from 'styled-system/recipes'
export const Dialog = {
Root: RadixDialog.Root,
Trigger: RadixDialog.Trigger,
Close: RadixDialog.Close,
Portal: RadixDialog.Portal,
Overlay: forwardRef<HTMLDivElement, RadixDialog.DialogOverlayProps>((props, ref) => {
const styles = dialogRecipe()
return <RadixDialog.Overlay ref={ref} {...props} className={cn(styles.overlay, props.className)} />
}),
Content: forwardRef<HTMLDivElement, RadixDialog.DialogContentProps>((props, ref) => {
const styles = dialogRecipe()
return <RadixDialog.Content ref={ref} {...props} className={cn(styles.content, props.className)} />
}),
Title: RadixDialog.Title,
Description: RadixDialog.Description,
}→ 앱 코드는 import { Dialog } from '@your-co/ds'만 한다. Radix 의존성이 한 곳에 격리된다.
라이브러리 선택 가이드
What-if — 잘못 쓰면 어떻게 깨지는가
-
함정 1 — 앱 코드에서 Radix를 직접 import
- 증상: 100개 파일이
@radix-ui/*에 의존. 버전 업그레이드, 라이브러리 교체가 거의 불가. - 대응: 디자인 시스템 패키지가 한 번 wrap. 앱은
@your-co/ds만 본다.
- 증상: 100개 파일이
-
함정 2 — Radix와 styled 라이브러리를 둘 다 쓰기
- 증상: 같은 종류의 Dialog가 두 종류 — Radix-기반과 MUI-기반. 디자인 일관성 깨짐.
- 대응: 디자인 시스템 시점에 하나만 채택. 통상 headless 쪽.
-
함정 3 — focus trap·portal·SSR 직접 만들기
- 증상: 자기만의 Dialog 구현 → 6개월 후 접근성 감사에서 폭망. iOS Safari에서 scroll lock 안 됨.
- 대응: 직접 만들지 마라. Radix Dialog의 코드(수천 줄)를 한 줄도 다시 쓸 가치 없다.
-
함정 4 — className을 무시하는 자식
- 증상: Radix
Dialog.Trigger의 default 마크업에 className만 주면 OK인데,asChild로 우리<Button>을 넣으면 Button이 className을 안 받음. - 대응: 우리 컴포넌트가 className을 forward + Tailwind/Panda 클래스 머지를 정확히 처리.
- 증상: Radix
-
함정 5 — server component 안에서 직접 사용
- 증상: Radix는 client hook을 쓰므로 RSC 안에 못 둠. 빌드 에러.
- 대응: 디자인 시스템 컴포넌트는
'use client'선언. 또는 Next 13+의 boundary 위치 신중히.
-
함정 6 — data-state를 무시하고 자기 state로 스타일
- 증상:
const [open, setOpen] = useState(false)를 className에 직접 매핑. Radix의 애니메이션 끝까지 살아있는 unmount 같은 미세 동작이 깨짐. - 대응: data attribute만 보고 스타일. (자세한 건 다음 챕터.)
- 증상:
Insight — Behavior-View 분리의 긴 역사
웹 UI에서 동작과 외형 분리는 jQuery 시대(2008년경)의 jQuery UI 때부터 시도되었지만, 당시는 CSS 클래스 hook에 머물렀다.
2018년 Segun Adebayo가 만든 Chakra UI는 동작은 styled-system이, 외형은 emotion이라는 분리를 React 생태계에 가져왔지만 완전 styled였다 — 사용자가 디자인을 못 바꿈.
2020년 Modulz(현 WorkOS) 팀이 Radix Primitives를 출시했다. “Behavior + Accessibility + Marketing-free unstyled” — Chakra의 한계를 정확히 갈랐다. 이때부터 headless라는 단어가 디자인 시스템 어휘에 자리잡았다.
2022년 shadcn/ui가 Radix + Tailwind를 한 묶음으로 묶어내며 폭발했다. npm install이 아니라 코드 복사로 컴포넌트를 받는 방식이 디자인 시스템의 새 배포 모델을 제안했다 (8장에서 다룸).
현재 합의: “복잡한 컴포넌트를 만들 거라면 headless 라이브러리 위에 디자인을 입혀라. 처음부터 만들지 마라.”
예외는 너무 단순한 컴포넌트(Button, Card, Stack) — 이건 직접 만드는 게 더 가볍다. Radix는 Tabs, Accordion, Dialog, DropdownMenu, ContextMenu, Toolbar, Toast, Tooltip, Popover, NavigationMenu, Select 같은 동작이 복잡한 컴포넌트만 제공한다. 이 경계 감각이 라이브러리 설계의 핵심이다.
요약
- Headless 라이브러리는 동작·ARIA·키보드·focus·portal을 책임, 우리는 클래스만 입힘.
- React 진영의 대안: Radix Primitives (사실상 표준), Headless UI, React Aria, Ariakit. Vue는 Reka UI, Svelte는 Melt UI.
- 자기 직접 만들지 마라. Dialog 하나에 동작·접근성 디테일이 9가지.
- 디자인 시스템 패키지가 한 번 wrap하여 라이브러리 의존성을 격리하라.
- 스타일링은 data attribute 기반 — 다음 챕터의 주제.