🧩 Design System5. Composition (Slot·Polymorphism)Headless UI Pattern — 동작은 우리가, 스타일은 너가

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 trapDialog 열리면 안으로 focus 이동, Tab이 밖으로 못 나감, 닫히면 trigger로 복귀
Outside clickOverlay 클릭 시 닫힘 (단, 드래그로 시작한 클릭은 제외)
Escape keyESC로 닫힘
Inert background뒤의 콘텐츠가 스크린리더에 안 읽힘 (aria-hidden, inert attr)
Scroll lock배경 스크롤 방지 (iOS는 다른 트릭 필요)
ARIArole="dialog", aria-modal, aria-labelledby, aria-describedby
Mount/unmount 애니메이션닫힘 애니메이션이 끝나야 DOM에서 제거
Portalbody 최하단에 렌더 (z-index, 스택 컨텍스트 회피)
SSRhydration 깨짐 방지, controlled state 유지

하나의 컴포넌트에 이 모든 게 들어간다. Dialog만이 아니다. Menu, Combobox, Listbox, DatePicker, Toast, Toolbar, Tooltip — 다 비슷한 깊이다.

풀려는 문제이전의 해법한계
복잡한 ARIA·키보드·focus 동작회사마다 직접 작성매번 버그, 접근성 검증 안 됨
Bootstrap·MUI 같은 styled 라이브러리디자인이 고정우리 토큰을 입히기 어려움
회사 디자인 시스템과 통합styled 라이브러리를 overrideCSS 특이도 전쟁

Headless 라이브러리의 약속:

“버튼 5px만한 외형은 너희가 그리되, 그 안의 복잡한 동작은 우리가 책임진다.”


How — 동작-외형 분리의 구조

핵심 메커니즘: Headless 라이브러리는 자식 element에 data attribute를 부여한다 — data-state="open", data-disabled, data-orientation="vertical". 우리는 그 attribute에 반응하는 CSS만 작성하면 된다.


What — 6개 라이브러리 지형도

라이브러리프레임워크스타일특징
Radix PrimitivesReactunstyled사실상 표준. shadcn/ui의 토대. asChild 패턴 발원지
Headless UIReact, VueunstyledTailwind 팀 작품. Listbox/Combobox/Menu/Dialog/Transition 등
React Aria (Adobe)Reacthook-based어댑티브 입력 (마우스/터치/키보드/펜) 가장 정교
AriakitReactunstyledReact Aria보다 가볍고, Radix보다 컴포넌트 다양
Reka UI (구 Radix Vue)VueunstyledRadix Primitives의 Vue 포팅
Melt UISveltebuilder-basedSvelte 컨벤션에 맞춘 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만 본다.
  • 함정 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 클래스 머지를 정확히 처리.
  • 함정 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/uiRadix + 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 기반 — 다음 챕터의 주제.