Radix UI
UI 컴포넌트 라이브러리를 선택할 때 항상 마주치는 딜레마가 있다. Material UI나 Ant Design 같은 풀 스타일드 라이브러리를 쓰면 빠르게 개발할 수 있지만, 디자인을 커스텀하려는 순간 라이브러리의 스타일과 싸워야 한다. !important를 남발하거나, 내부 클래스명을 뒤져서 오버라이드하거나, 결국 포기하고 라이브러리 기본 디자인을 그대로 쓰게 된다.
반대로 모든 걸 직접 만들면 디자인 자유도는 100%지만, 키보드 내비게이션, 스크린 리더 지원, 포커스 트랩, 외부 클릭 감지 같은 접근성과 인터랙션 로직을 전부 구현해야 한다. 드롭다운 메뉴 하나를 제대로 만들려면 생각보다 엄청난 양의 코드가 필요하다.
Radix UI는 이 딜레마를 해결하기 위해 Headless UI 접근 방식을 택한다. 컴포넌트의 동작과 접근성은 라이브러리가 처리하고, 스타일은 개발자가 완전히 자유롭게 정의한다. "보이지 않는 뼈대"를 제공하는 셈이다.
Headless UI란
Headless UI 컴포넌트는 기능은 있지만 스타일이 없는 컴포넌트다. 일반적인 UI 라이브러리와 비교하면 차이가 명확하다.
| 구분 | 풀 스타일드 (MUI, Ant Design) | Headless (Radix UI) |
|---|---|---|
| 스타일 | 기본 스타일 포함 | 스타일 없음 |
| 커스텀 난이도 | 오버라이드 필요 | 처음부터 자유 |
| 접근성 | 내장 | 내장 |
| 번들 크기 | CSS 포함으로 큼 | 로직만 포함으로 작음 |
| 디자인 시스템 | 라이브러리 디자인에 종속 | 자체 디자인 시스템 적용 가능 |
Headless 방식이 유리한 상황은 명확하다. 자체 디자인 시스템이 있거나, Tailwind CSS처럼 유틸리티 퍼스트 CSS를 사용하거나, 브랜드 아이덴티티에 맞는 고유한 UI가 필요할 때다.
Radix UI의 구조
Radix UI는 크게 두 가지로 나뉜다.
Radix Primitives
핵심 Headless 컴포넌트 패키지다. 각 컴포넌트가 독립적인 npm 패키지로 분리되어 있어서 필요한 것만 설치할 수 있다.
npm install @radix-ui/react-dialog
npm install @radix-ui/react-dropdown-menu
npm install @radix-ui/react-tooltip
이 방식의 장점은 트리쉐이킹 없이도 번들 크기를 최소화할 수 있다는 것이다. Dialog만 쓴다면 Dialog 패키지만 설치하면 된다.
Radix Themes
Primitives 위에 기본 스타일을 입힌 "opinionated" 컴포넌트 세트다. Headless가 부담스럽거나 빠르게 프로토타이핑하고 싶을 때 사용한다. 하지만 Radix UI의 진가는 Primitives에 있으므로, 이 글에서는 Primitives에 집중한다.
컴포넌트 합성 패턴
Radix UI의 가장 큰 특징은 Compound Component 패턴을 사용한다는 점이다. 하나의 모놀리식 컴포넌트에 수십 개의 props를 넘기는 대신, 여러 하위 컴포넌트를 조합해서 UI를 구성한다.
Dialog 예시
MUI 방식의 Dialog를 보면 props가 한곳에 몰려 있다:
// MUI 스타일 - props가 많고 커스텀이 어렵다
<Dialog
open={open}
onClose={handleClose}
title="삭제 확인"
content="정말 삭제하시겠습니까?"
actions={<Button onClick={handleDelete}>삭제</Button>}
maxWidth="sm"
fullWidth
disableEscapeKeyDown
/>
Radix UI는 각 역할을 하위 컴포넌트로 분리한다:
import * as Dialog from "@radix-ui/react-dialog";
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger asChild>
<button className="btn-danger">삭제</button>
</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-96">
<Dialog.Title className="text-lg font-bold">
삭제 확인
</Dialog.Title>
<Dialog.Description className="mt-2 text-gray-600">
정말 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
</Dialog.Description>
<div className="mt-4 flex justify-end gap-2">
<Dialog.Close asChild>
<button className="btn-secondary">취소</button>
</Dialog.Close>
<button className="btn-danger" onClick={handleDelete}>
삭제
</button>
</div>
<Dialog.Close asChild>
<button className="absolute top-2 right-2" aria-label="닫기">
✕
</button>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
코드가 더 길어 보이지만, 각 부분이 무엇을 하는지 명확하고, 스타일과 구조를 완전히 제어할 수 있다. Title과 Content 사이에 커스텀 UI를 자유롭게 넣을 수 있고, Overlay의 투명도나 Content의 위치도 CSS 클래스 하나로 바꿀 수 있다.
하위 컴포넌트 역할
Radix UI의 대부분의 컴포넌트는 비슷한 구조를 따른다:
| 하위 컴포넌트 | 역할 |
|---|---|
Root | 상태 관리 (open/close, value 등) |
Trigger | 컴포넌트를 활성화하는 트리거 요소 |
Portal | DOM 트리 외부에 렌더링 (body 하단) |
Overlay | 배경 오버레이 |
Content | 실제 콘텐츠 영역 |
Title | 접근성을 위한 제목 (aria-labelledby 자동 연결) |
Description | 접근성을 위한 설명 (aria-describedby 자동 연결) |
Close | 닫기 동작을 수행하는 요소 |
asChild 패턴
Radix UI에서 가장 자주 마주치는 prop이 asChild다. 이 prop의 존재 이유를 이해하면 Radix UI의 설계 철학이 보인다.
asChild를 사용하지 않으면 Radix는 기본 HTML 요소를 렌더링한다:
// <button> 태그가 렌더링됨
<Dialog.Trigger>열기</Dialog.Trigger>
하지만 이미 스타일이 적용된 커스텀 버튼 컴포넌트를 사용하고 싶다면?
// asChild로 자식 요소에 Radix의 동작을 위임
<Dialog.Trigger asChild>
<Button variant="primary" size="lg">열기</Button>
</Dialog.Trigger>
asChild를 사용하면 Radix는 자체 요소를 렌더링하지 않고, 자식 요소에 필요한 props(이벤트 핸들러, aria 속성 등)를 전달(merge)한다. 내부적으로는 @radix-ui/react-slot 패키지의 Slot 컴포넌트가 이 동작을 처리한다.
// Slot의 동작 원리 (단순화)
function Slot({ children, ...slotProps }) {
// 자식 요소에 slotProps를 병합해서 렌더링
return cloneElement(children, mergeProps(slotProps, children.props));
}
주의할 점은 asChild를 사용할 때 자식이 반드시 하나여야 한다는 것이다. 여러 자식을 넘기면 동작하지 않는다.
// ❌ 잘못된 사용
<Dialog.Trigger asChild>
<Icon />
<span>열기</span>
</Dialog.Trigger>
// ✅ 하나의 요소로 감싸기
<Dialog.Trigger asChild>
<button>
<Icon />
<span>열기</span>
</button>
</Dialog.Trigger>
접근성(a11y) 자동 처리
Radix UI를 쓰는 가장 큰 이유 중 하나가 접근성이다. 직접 구현하면 빠뜨리기 쉬운 것들을 Radix가 자동으로 처리한다.
WAI-ARIA 패턴 준수
각 컴포넌트는 WAI-ARIA Authoring Practices에 맞는 역할(role)과 속성(aria-*)을 자동으로 설정한다.
<Dialog.Content>
<Dialog.Title>설정</Dialog.Title>
<Dialog.Description>앱 설정을 변경합니다.</Dialog.Description>
</Dialog.Content>
이 코드만 작성하면 Radix가 내부적으로 다음을 처리한다:
- Content에
role="dialog",aria-modal="true"자동 설정 - Title의 id를 생성해서 Content의
aria-labelledby에 연결 - Description의 id를 생성해서 Content의
aria-describedby에 연결
키보드 내비게이션
모든 컴포넌트가 키보드로 완전히 조작 가능하다.
| 컴포넌트 | 키보드 동작 |
|---|---|
| Dialog | Escape로 닫기, Tab으로 내부 포커스 이동 |
| DropdownMenu | 화살표 키로 항목 이동, Enter로 선택, Escape로 닫기 |
| Tabs | 화살표 키로 탭 전환, Home/End로 처음/마지막 탭 |
| Accordion | 화살표 키로 항목 이동, Space/Enter로 토글 |
| Select | 화살표 키로 옵션 이동, 문자 입력으로 검색 |
포커스 관리
Dialog나 DropdownMenu가 열릴 때 포커스를 자동으로 내부로 이동하고, 닫힐 때 트리거로 되돌린다. Dialog에서는 포커스 트랩(focus trap)이 자동 적용되어 Tab 키를 눌러도 Dialog 바깥으로 포커스가 나가지 않는다.
<Dialog.Content
onOpenAutoFocus={(e) => {
// 기본 포커스 동작 변경 가능
e.preventDefault();
emailInputRef.current?.focus();
}}
onCloseAutoFocus={(e) => {
// 닫힐 때 포커스 대상 변경
e.preventDefault();
triggerRef.current?.focus();
}}
>
제어/비제어 컴포넌트
Radix의 모든 상태 관리 컴포넌트는 제어(controlled)와 비제어(uncontrolled) 모드를 모두 지원한다.
비제어 모드
상태를 Radix가 내부적으로 관리한다. 간단한 경우에 적합하다.
// 비제어 - 기본값만 설정하면 Radix가 알아서 관리
<Dialog.Root defaultOpen={false}>
<Dialog.Trigger>열기</Dialog.Trigger>
<Dialog.Content>내용</Dialog.Content>
</Dialog.Root>
<Accordion.Root type="single" defaultValue="item-1">
<Accordion.Item value="item-1">...</Accordion.Item>
<Accordion.Item value="item-2">...</Accordion.Item>
</Accordion.Root>
제어 모드
상태를 외부에서 직접 관리한다. 열기/닫기 타이밍을 제어하거나, 상태에 따라 다른 로직을 실행해야 할 때 사용한다.
const [open, setOpen] = useState(false);
// 제어 - 상태를 직접 관리
<Dialog.Root open={open} onOpenChange={setOpen}>
<Dialog.Trigger>열기</Dialog.Trigger>
<Dialog.Content>
<form onSubmit={(e) => {
e.preventDefault();
submitForm();
setOpen(false); // 폼 제출 후 수동으로 닫기
}}>
...
</form>
</Dialog.Content>
</Dialog.Root>
패턴 자체는 React의 제어/비제어 인풋 패턴과 동일하다. value/onChange 대신 open/onOpenChange, value/onValueChange 같은 prop 이름을 사용할 뿐이다.
Portal
Dialog, DropdownMenu, Tooltip 같은 오버레이 컴포넌트는 Portal로 감싸서 DOM 트리 최상위에 렌더링한다.
<Dialog.Portal>
<Dialog.Overlay />
<Dialog.Content>...</Dialog.Content>
</Dialog.Portal>
Portal이 필요한 이유는 CSS overflow: hidden이나 z-index 스태킹 컨텍스트 문제 때문이다. 부모 요소에 overflow: hidden이 설정되어 있으면 자식 요소가 잘리는데, Portal로 body 하단에 렌더링하면 이 문제를 피할 수 있다.
Portal의 렌더링 대상을 변경할 수도 있다:
<Dialog.Portal container={customContainerRef.current}>
...
</Dialog.Portal>
애니메이션 적용
Radix 컴포넌트는 data-state 속성을 통해 현재 상태를 CSS에 노출한다. 이를 활용해서 열기/닫기 애니메이션을 적용할 수 있다.
CSS 애니메이션
/* Dialog Overlay */
.dialog-overlay[data-state="open"] {
animation: fadeIn 200ms ease-out;
}
.dialog-overlay[data-state="closed"] {
animation: fadeOut 200ms ease-in;
}
/* Dialog Content */
.dialog-content[data-state="open"] {
animation: slideIn 200ms ease-out;
}
.dialog-content[data-state="closed"] {
animation: slideOut 200ms ease-in;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes slideIn {
from { transform: translate(-50%, -50%) scale(0.95); opacity: 0; }
to { transform: translate(-50%, -50%) scale(1); opacity: 1; }
}
Tailwind CSS와 함께
Tailwind의 data-* 변형을 사용하면 더 간결하다:
<Dialog.Overlay className="
fixed inset-0 bg-black/50
data-[state=open]:animate-fadeIn
data-[state=closed]:animate-fadeOut
" />
<Dialog.Content className="
fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2
bg-white rounded-lg p-6
data-[state=open]:animate-contentShow
data-[state=closed]:animate-contentHide
" />
tailwind.config.js에 커스텀 애니메이션을 정의한다:
// tailwind.config.js
module.exports = {
theme: {
extend: {
keyframes: {
fadeIn: {
from: { opacity: "0" },
to: { opacity: "1" },
},
contentShow: {
from: {
opacity: "0",
transform: "translate(-50%, -48%) scale(0.96)",
},
to: {
opacity: "1",
transform: "translate(-50%, -50%) scale(1)",
},
},
},
animation: {
fadeIn: "fadeIn 200ms ease-out",
contentShow: "contentShow 200ms ease-out",
},
},
},
};
닫기 애니메이션이 잘리는 문제
Radix는 기본적으로 컴포넌트가 닫히면 즉시 DOM에서 제거한다. 닫기 애니메이션이 재생되기도 전에 사라지는 것이다. 이를 해결하려면 forceMount를 사용해서 항상 DOM에 마운트 상태를 유지하고, 직접 보이기/숨기기를 제어한다.
<Dialog.Portal forceMount>
<Dialog.Overlay
className={cn(
"fixed inset-0 bg-black/50",
open ? "animate-fadeIn" : "animate-fadeOut"
)}
/>
<Dialog.Content
className={cn(
"fixed ...",
open ? "animate-slideIn" : "animate-slideOut"
)}
/>
</Dialog.Portal>
또는 Framer Motion의 AnimatePresence와 조합하는 방법도 있다:
import { AnimatePresence, motion } from "framer-motion";
<Dialog.Root open={open} onOpenChange={setOpen}>
<AnimatePresence>
{open && (
<Dialog.Portal forceMount>
<Dialog.Overlay asChild forceMount>
<motion.div
className="fixed inset-0 bg-black/50"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
/>
</Dialog.Overlay>
<Dialog.Content asChild forceMount>
<motion.div
className="fixed top-1/2 left-1/2 ..."
initial={{ opacity: 0, scale: 0.95, x: "-50%", y: "-48%" }}
animate={{ opacity: 1, scale: 1, x: "-50%", y: "-50%" }}
exit={{ opacity: 0, scale: 0.95, x: "-50%", y: "-48%" }}
/>
</Dialog.Content>
</Dialog.Portal>
)}
</AnimatePresence>
</Dialog.Root>
주요 컴포넌트 정리
Radix Primitives에서 제공하는 주요 컴포넌트와 각각의 용도를 정리한다.
오버레이 계열
| 컴포넌트 | 용도 | 특징 |
|---|---|---|
| Dialog | 모달 대화상자 | 포커스 트랩, 배경 스크롤 방지 |
| AlertDialog | 확인/취소 대화상자 | Dialog와 유사하나 배경 클릭으로 닫히지 않음 |
| DropdownMenu | 우클릭/버튼 메뉴 | 하위 메뉴, 체크/라디오 항목 지원 |
| ContextMenu | 우클릭 컨텍스트 메뉴 | DropdownMenu와 구조 동일, 트리거만 다름 |
| Tooltip | 호버 툴팁 | 지연 시간, Provider로 전역 설정 |
| Popover | 클릭 팝오버 | 자유로운 콘텐츠, 위치 자동 조정 |
| HoverCard | 호버 카드 | 링크 미리보기 등에 사용 |
폼/입력 계열
| 컴포넌트 | 용도 | 특징 |
|---|---|---|
| Select | 드롭다운 셀렉트 | 네이티브 select 대체, 키보드 검색 |
| Checkbox | 체크박스 | 부분 선택(indeterminate) 지원 |
| RadioGroup | 라디오 그룹 | 방향키로 선택 이동 |
| Switch | 토글 스위치 | on/off 상태 |
| Slider | 슬라이더 | 범위 선택, 다중 thumb |
| Toggle | 토글 버튼 | pressed 상태 관리 |
| ToggleGroup | 토글 그룹 | 단일/다중 선택 |
레이아웃/내비게이션 계열
| 컴포넌트 | 용도 | 특징 |
|---|---|---|
| Accordion | 아코디언 | 단일/다중 열기, 애니메이션 |
| Tabs | 탭 | 방향키 내비게이션, 수동/자동 활성화 |
| NavigationMenu | 내비게이션 메뉴 | 대형 드롭다운 메뉴 |
| Menubar | 메뉴바 | 데스크톱 앱 스타일 메뉴 |
| Collapsible | 접기/펼치기 | 단순 열기/닫기 영역 |
shadcn/ui와의 관계
Radix UI를 이야기할 때 빠질 수 없는 것이 shadcn/ui다. shadcn/ui는 Radix Primitives + Tailwind CSS로 만든 컴포넌트 모음인데, 일반적인 npm 패키지와 다르게 소스 코드를 프로젝트에 직접 복사하는 방식이다.
npx shadcn@latest add dialog
이 명령을 실행하면 components/ui/dialog.tsx 파일이 프로젝트에 생성된다. 의존성이 아니라 소스 코드이므로 자유롭게 수정할 수 있다.
// components/ui/dialog.tsx (생성된 파일)
import * as DialogPrimitive from "@radix-ui/react-dialog";
const DialogOverlay = React.forwardRef<...>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in ...",
className
)}
{...props}
/>
));
shadcn/ui를 쓰면 Radix의 Compound Component 패턴을 매번 작성하지 않아도 되고, 적절한 기본 스타일이 이미 적용되어 있다. Radix UI를 직접 사용하는 것과 shadcn/ui를 사용하는 것의 차이:
| 구분 | Radix 직접 사용 | shadcn/ui |
|---|---|---|
| 스타일 | 직접 작성 | Tailwind 기본 스타일 포함 |
| 설치 | npm 패키지 | 소스 코드 복사 |
| 커스텀 | className 전달 | 소스 코드 직접 수정 |
| 학습 곡선 | Radix API 이해 필요 | 상대적으로 낮음 |
| 적합한 상황 | 완전한 디자인 자유도 | 빠른 개발 + 적당한 커스텀 |
대부분의 프로젝트에서는 shadcn/ui로 시작하고, 필요할 때 생성된 소스 코드를 수정하는 방식이 효율적이다.
대안 비교
Headless UI 라이브러리는 Radix UI만 있는 것이 아니다.
Headless UI (Tailwind Labs)
Tailwind CSS 공식 팀이 만든 Headless 컴포넌트 라이브러리다. Radix보다 컴포넌트 수가 적지만(약 10개), Tailwind와의 통합이 자연스럽고 Transition 컴포넌트가 내장되어 있다.
// Headless UI의 Transition
<Transition show={open} enter="transition-opacity" enterFrom="opacity-0" ...>
<Dialog.Panel>...</Dialog.Panel>
</Transition>
Ark UI
Radix와 유사한 API를 가진 Headless 라이브러리로, React뿐만 아니라 Vue, Solid에서도 사용할 수 있다. Zag.js라는 상태 머신 라이브러리 기반이라 프레임워크 간 동작이 일관적이다.
React Aria (Adobe)
Adobe가 만든 Headless 라이브러리로, 접근성에 가장 엄격하다. Hook 기반 API를 제공해서 컴포넌트가 아닌 Hook을 사용한다.
// React Aria는 Hook 기반
const { buttonProps } = useButton(props, ref);
return <button {...buttonProps} ref={ref}>{children}</button>;
| 비교 | Radix UI | Headless UI | React Aria |
|---|---|---|---|
| 컴포넌트 수 | 30+ | ~10 | 40+ |
| API 스타일 | Compound Component | Compound Component | Hooks |
| 프레임워크 | React | React, Vue | React |
| 접근성 수준 | 높음 | 높음 | 매우 높음 |
| 생태계 | shadcn/ui | Tailwind UI (유료) | Spectrum (Adobe) |
| 번들 크기 | 작음 | 작음 | 비교적 큼 |
Radix UI가 가장 널리 사용되는 이유는 컴포넌트 수가 충분하고, API가 직관적이며, shadcn/ui라는 강력한 생태계가 있기 때문이다.
실전 팁
1. Provider 설정
Tooltip은 TooltipProvider로 감싸야 한다. 보통 앱 루트에 한 번만 설정한다.
import * as Tooltip from "@radix-ui/react-tooltip";
function App() {
return (
<Tooltip.Provider delayDuration={300} skipDelayDuration={100}>
{children}
</Tooltip.Provider>
);
}
delayDuration은 호버 후 툴팁이 표시되기까지의 지연 시간이고, skipDelayDuration은 다른 툴팁으로 빠르게 이동할 때 지연 없이 바로 표시하는 시간이다.
2. 스크롤 방지
Dialog가 열렸을 때 배경 스크롤을 방지하려면 body에 overflow: hidden을 설정해야 한다. Radix Dialog는 이를 자동으로 처리하지만, iOS Safari에서 추가 처리가 필요할 수 있다.
3. 중첩 모달
Dialog 안에서 AlertDialog를 열거나, DropdownMenu 안에서 Dialog를 열어야 하는 경우가 있다. Radix는 이런 중첩을 지원하지만, Portal의 렌더링 순서와 z-index를 주의해야 한다.
<Dialog.Root>
<Dialog.Content>
{/* Dialog 안에서 AlertDialog */}
<AlertDialog.Root>
<AlertDialog.Trigger>삭제</AlertDialog.Trigger>
<AlertDialog.Portal>
<AlertDialog.Overlay className="z-[60]" />
<AlertDialog.Content className="z-[60]">
정말 삭제?
</AlertDialog.Content>
</AlertDialog.Portal>
</AlertDialog.Root>
</Dialog.Content>
</Dialog.Root>
4. SSR 호환
Radix 컴포넌트는 Next.js 같은 SSR 환경에서도 동작한다. Portal은 클라이언트 사이드에서만 렌더링되므로 hydration 불일치 문제가 발생하지 않는다.
정리
- Radix UI는 Headless UI 접근법으로 동작/접근성은 라이브러리가, 스타일은 개발자가 담당하는 구조다
- Compound Component 패턴과
asChild로 구조와 스타일을 완전히 제어할 수 있고, WAI-ARIA 키보드 내비게이션/포커스 관리가 자동 처리된다 - shadcn/ui는 Radix Primitives + Tailwind CSS를 소스 코드 복사 방식으로 제공하며, 대부분의 프로젝트에서 Radix를 가장 실용적으로 사용하는 방법이다