접근성을 올바르게 구현하는 것은 어렵습니다. 다이얼로그 하나를 제대로 만들려면 포커스 트랩, 스크롤 잠금, 배경 클릭으로 닫기, 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
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", 배경 스크롤 잠금을 모두 자동으로 처리합니다.
Accessibility: 장애 여부에 관계없이 모든 사용자가 인터페이스를 사용할 수 있도록 보장하는 기준
Radix는 WAI-ARIA(Web Accessibility Initiative - Accessible Rich Internet Applications) 명세를 내장합니다. 개발자가 별도로 aria-* 속성을 추가하지 않아도 됩니다.
자동으로 처리되는 접근성 기능:
role), 속성(aria-*), 상태(aria-expanded, aria-selected) 자동 관리// Radix가 자동으로 추가하는 속성 예시 (렌더링된 HTML)
<button
role="combobox"
aria-expanded="false"
aria-controls="select-content"
aria-required="true"
>
옵션 선택
</button>
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 | |
| +------+ | | +----------+ |
+-----------+ +----------------+
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>
);
}
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를 설정하면 공간이 부족할 때 반대 방향으로 자동 이동합니다.
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와 클래스를 병합하여 전달합니다.
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 | 스타일 없이 동작과 접근성만 제공하는 기본 단위 |
| Accessibility | WAI-ARIA, 키보드, 포커스 관리 자동 처리 |
| Portal | DOM 트리 외부 렌더링으로 레이어 문제 해결 |
| Dialog | 모달 다이얼로그의 완전한 접근성 구현 |
| Popover | 뷰포트 인식 플로팅 콘텐츠 |
| Slot | props 병합을 통한 렌더링 위임 유틸리티 |
| Composition | asChild로 자식 요소에 동작 위임 |
처음부터 접근성을 고려한 컴포넌트를 만드는 것은 많은 시간이 필요합니다. Radix는 그 기반 작업을 제공하여 품질 높은 컴포넌트를 빠르게 구축할 수 있게 합니다.