개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

Radix UI — 접근성을 고려한 Headless 컴포넌트

2026년 6월 6일
React접근성Headless UIRadix UI

접근성을 올바르게 구현하는 것은 어렵습니다. 다이얼로그 하나를 제대로 만들려면 포커스 트랩, 스크롤 잠금, 배경 클릭으로 닫기, ESC 키 처리, WAI-ARIA 속성(role="dialog", aria-modal, aria-labelledby) 등을 모두 처리해야 합니다. 버튼과 인풋처럼 단순한 요소와 달리, Select, Tooltip, Popover 같은 복잡한 컴포넌트는 구현 난도가 훨씬 높습니다.

Radix UI는 이 복잡성을 대신 처리합니다. 동작과 접근성만 제공하고 스타일은 전혀 포함하지 않습니다. 스타일링은 온전히 개발자의 몫이며, Tailwind CSS, CSS Modules, styled-components 등 무엇이든 사용할 수 있습니다.

설치

컴포넌트별로 독립 패키지를 설치합니다. 필요한 것만 추가하면 됩니다.

npm install @radix-ui/react-dialog
npm install @radix-ui/react-popover
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tooltip

1. Primitive

Radix의 모든 컴포넌트는 Primitive입니다. 시각적 스타일을 강요하지 않으며, 동작 명세와 접근성 표준만 구현합니다.

import * as Dialog from "@radix-ui/react-dialog";

// Radix는 스타일을 전혀 제공하지 않음
// className으로 직접 스타일을 입힘
function MyDialog() {
  return (
    <Dialog.Root>
      <Dialog.Trigger className="px-4 py-2 bg-blue-600 text-white rounded">
        열기
      </Dialog.Trigger>

      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/50" />
        <Dialog.Content className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
                                   bg-white rounded-lg p-6 w-[480px] shadow-xl">
          <Dialog.Title className="text-xl font-bold">제목</Dialog.Title>
          <Dialog.Description className="mt-2 text-gray-600">
            설명 텍스트
          </Dialog.Description>
          <Dialog.Close className="absolute top-4 right-4">✕</Dialog.Close>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

이 코드에서 Radix는 포커스 트랩, ESC 키 닫기, aria-modal, role="dialog", 배경 스크롤 잠금을 모두 자동으로 처리합니다.


2. Accessibility

Accessibility: 장애 여부에 관계없이 모든 사용자가 인터페이스를 사용할 수 있도록 보장하는 기준

Radix는 WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications) 명세를 내장합니다. 개발자가 별도로 aria-* 속성을 추가하지 않아도 됩니다.

자동으로 처리되는 접근성 기능:

  • WAI-ARIA 역할(role), 속성(aria-*), 상태(aria-expanded, aria-selected) 자동 관리
  • 키보드 탐색: Tab, Arrow Key, Enter, Space, ESC 등 표준 키보드 패턴
  • 포커스 관리: 열릴 때 첫 포커스 이동, 닫힐 때 트리거로 포커스 복귀
  • 포커스 트랩: 모달이 열려 있는 동안 포커스가 모달 안에 머뭄
// Radix가 자동으로 추가하는 속성 예시 (렌더링된 HTML)
<button
  role="combobox"
  aria-expanded="false"
  aria-controls="select-content"
  aria-required="true"
>
  옵션 선택
</button>

3. Portal

Portal: 컴포넌트를 현재 DOM 트리에서 꺼내 document.body 아래에 렌더링하는 기능

모달, 툴팁, 드롭다운 같은 컴포넌트는 부모의 overflow: hidden이나 z-index 스택 문제로 의도치 않게 잘립니다. Portal은 이를 해결합니다.

import * as Dialog from "@radix-ui/react-dialog";
import * as Tooltip from "@radix-ui/react-tooltip";

// Dialog.Portal — document.body에 마운트됨
<Dialog.Portal>
  <Dialog.Overlay />
  <Dialog.Content>...</Dialog.Content>
</Dialog.Portal>

// 커스텀 컨테이너 지정 가능
<Dialog.Portal container={document.getElementById("modal-root")}>
  <Dialog.Overlay />
  <Dialog.Content>...</Dialog.Content>
</Dialog.Portal>
  Component Tree            DOM Tree
  +-----------+             +----------------+
  | App       |             | body           |
  |  +------+ |             |  +----------+  |
  |  | Card | |             |  | App ...  |  |
  |  |  +---+ |   Portal    |  +----------+  |
  |  |  |Dlg| | ==========> |  +----------+  |
  |  |  +---+ |             |  | Dialog   |  |
  |  +------+ |             |  +----------+  |
  +-----------+             +----------------+

4. Dialog

Dialog: WAI-ARIA dialog 패턴을 구현한 모달 컴포넌트

Dialog는 Radix에서 가장 복잡한 접근성 구현이 필요한 컴포넌트 중 하나입니다. 포커스 트랩, 배경 클릭 감지, ESC 키 처리, aria-labelledby와 aria-describedby 연결을 모두 자동 처리합니다.

import * as Dialog from "@radix-ui/react-dialog";

function ConfirmDialog({
  onConfirm,
  onCancel,
}: {
  onConfirm: () => void;
  onCancel: () => void;
}) {
  return (
    <Dialog.Root>
      <Dialog.Trigger asChild>
        <button className="btn-danger">삭제</button>
      </Dialog.Trigger>

      <Dialog.Portal>
        <Dialog.Overlay className="fixed inset-0 bg-black/40 animate-fade-in" />
        <Dialog.Content
          className="fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
                     bg-white p-6 rounded-xl shadow-2xl w-[400px]"
          onEscapeKeyDown={onCancel}
          onInteractOutside={onCancel}
        >
          {/* Title과 Description은 스크린 리더에 자동 연결됨 */}
          <Dialog.Title className="text-lg font-bold text-red-600">
            항목 삭제
          </Dialog.Title>
          <Dialog.Description className="mt-2 text-sm text-gray-600">
            이 항목을 삭제하면 복구할 수 없습니다.
          </Dialog.Description>

          <div className="mt-6 flex justify-end gap-3">
            <Dialog.Close asChild>
              <button className="btn-secondary" onClick={onCancel}>
                취소
              </button>
            </Dialog.Close>
            <button className="btn-danger" onClick={onConfirm}>
              삭제 확인
            </button>
          </div>
        </Dialog.Content>
      </Dialog.Portal>
    </Dialog.Root>
  );
}

5. Popover

Popover는 트리거 요소 근처에 플로팅 콘텐츠를 표시합니다. 뷰포트 경계를 자동으로 감지하여 화면 밖으로 벗어나지 않도록 위치를 조정합니다.

import * as Popover from "@radix-ui/react-popover";

function FilterPopover() {
  return (
    <Popover.Root>
      <Popover.Trigger asChild>
        <button className="flex items-center gap-2 border rounded px-3 py-1.5">
          <span>필터</span>
        </button>
      </Popover.Trigger>

      <Popover.Portal>
        <Popover.Content
          className="bg-white rounded-lg shadow-xl p-4 w-64 border"
          side="bottom"       // 기본 위치: 아래
          align="start"       // 트리거 기준 정렬: 왼쪽
          sideOffset={8}      // 트리거와의 간격(px)
          avoidCollisions     // 뷰포트 충돌 시 자동 반전
        >
          <p className="text-sm font-medium mb-3">카테고리 선택</p>
          {/* 필터 내용 */}

          {/* 삼각형 화살표 */}
          <Popover.Arrow className="fill-white" />
        </Popover.Content>
      </Popover.Portal>
    </Popover.Root>
  );
}

side는 top | right | bottom | left, align은 start | center | end를 지원합니다. avoidCollisions를 설정하면 공간이 부족할 때 반대 방향으로 자동 이동합니다.


6. Slot

Slot: 자신의 props와 자식 컴포넌트의 props를 병합하여 자식에게 전달하는 합성 패턴 구현체

Slot은 Radix가 제공하는 합성 유틸리티입니다. 컴포넌트가 DOM 요소 대신 자식에게 자신의 동작을 위임할 때 사용합니다.

import { Slot } from "@radix-ui/react-slot";

// Slot을 사용하는 버튼 컴포넌트
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
  asChild?: boolean;
}

function Button({ asChild, children, className, ...props }: ButtonProps) {
  const Comp = asChild ? Slot : "button";

  return (
    <Comp
      className={`px-4 py-2 rounded bg-blue-600 text-white ${className}`}
      {...props}
    >
      {children}
    </Comp>
  );
}

// 사용 예시
// 1. 기본 — <button> 렌더링
<Button onClick={handleClick}>버튼</Button>

// 2. asChild — <a> 태그로 렌더링되지만 Button의 스타일과 props 적용
<Button asChild>
  <a href="/dashboard">대시보드로 이동</a>
</Button>

asChild가 활성화되면 Button이 <button>을 렌더링하는 대신, 자식인 <a>에 모든 props와 클래스를 병합하여 전달합니다.


7. Composition

Radix의 모든 컴포넌트는 asChild prop을 지원합니다. asChild는 Radix 컴포넌트가 자신의 DOM 요소 대신 자식 요소에 동작을 위임하게 합니다.

import * as Dialog from "@radix-ui/react-dialog";
import { Link } from "react-router-dom";

// asChild 없이 — Radix가 <button> 렌더링
<Dialog.Trigger>
  열기  {/* 결과: <button>열기</button> */}
</Dialog.Trigger>

// asChild 사용 — 자식 요소가 트리거 역할
<Dialog.Trigger asChild>
  <Link to="/modal">열기</Link>
  {/* 결과: <a href="/modal">열기</a> — Dialog 트리거 동작 포함 */}
</Dialog.Trigger>

// asChild 사용 — 커스텀 컴포넌트에 위임
<Dialog.Trigger asChild>
  <IconButton icon={<PlusIcon />} label="추가" />
</Dialog.Trigger>

이 패턴 덕분에 Radix 컴포넌트는 특정 HTML 요소에 종속되지 않습니다. <button>, <a>, 커스텀 컴포넌트 어디에든 동작을 연결할 수 있습니다.

  Dialog.Trigger (asChild)
        |
        | props 병합
        v
  <CustomButton>   <-- Radix의 onClick, aria-haspopup 등이 병합됨
        |
        v
  <button ...radix-props ...custom-props>
    자식 내용
  </button>

실전 패턴: 커스텀 디자인 시스템 구축

Radix를 기반으로 자신만의 컴포넌트를 만드는 일반적인 패턴입니다.

// components/ui/dialog.tsx
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { cn } from "@/lib/utils";

const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;

const DialogContent = React.forwardRef<
  React.ElementRef<typeof DialogPrimitive.Content>,
  React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
  <DialogPrimitive.Portal>
    <DialogPrimitive.Overlay className="fixed inset-0 bg-black/50 backdrop-blur-sm" />
    <DialogPrimitive.Content
      ref={ref}
      className={cn(
        "fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2",
        "bg-white rounded-xl shadow-2xl p-6 w-full max-w-lg",
        className
      )}
      {...props}
    >
      {children}
    </DialogPrimitive.Content>
  </DialogPrimitive.Portal>
));

export { Dialog, DialogTrigger, DialogContent };

이렇게 만든 컴포넌트는 Radix의 접근성을 유지하면서 프로젝트 디자인 시스템에 맞는 스타일을 갖추게 됩니다. 실제로 shadcn/ui가 이 방식으로 구축되어 있습니다.


정리

Radix UI는 UI 컴포넌트에서 가장 구현하기 어려운 부분인 접근성과 동작 명세를 담당합니다. 개발자는 스타일링에만 집중하면 됩니다.

개념역할
Primitive스타일 없이 동작과 접근성만 제공하는 기본 단위
AccessibilityWAI-ARIA, 키보드, 포커스 관리 자동 처리
PortalDOM 트리 외부 렌더링으로 레이어 문제 해결
Dialog모달 다이얼로그의 완전한 접근성 구현
Popover뷰포트 인식 플로팅 콘텐츠
Slotprops 병합을 통한 렌더링 위임 유틸리티
CompositionasChild로 자식 요소에 동작 위임

처음부터 접근성을 고려한 컴포넌트를 만드는 것은 많은 시간이 필요합니다. Radix는 그 기반 작업을 제공하여 품질 높은 컴포넌트를 빠르게 구축할 수 있게 합니다.

← 이전 글Motion — React 애니메이션 라이브러리
다음 글 →shadcn/ui — Radix UI와 Tailwind 기반 재사용 컴포넌트