junyeokk
Blog
React Ecosystem·2025. 11. 15

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 패키지로 분리되어 있어서 필요한 것만 설치할 수 있다.

bash
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가 한곳에 몰려 있다:

tsx
// MUI 스타일 - props가 많고 커스텀이 어렵다
<Dialog
  open={open}
  onClose={handleClose}
  title="삭제 확인"
  content="정말 삭제하시겠습니까?"
  actions={<Button onClick={handleDelete}>삭제</Button>}
  maxWidth="sm"
  fullWidth
  disableEscapeKeyDown
/>

Radix UI는 각 역할을 하위 컴포넌트로 분리한다:

tsx
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컴포넌트를 활성화하는 트리거 요소
PortalDOM 트리 외부에 렌더링 (body 하단)
Overlay배경 오버레이
Content실제 콘텐츠 영역
Title접근성을 위한 제목 (aria-labelledby 자동 연결)
Description접근성을 위한 설명 (aria-describedby 자동 연결)
Close닫기 동작을 수행하는 요소

asChild 패턴

Radix UI에서 가장 자주 마주치는 prop이 asChild다. 이 prop의 존재 이유를 이해하면 Radix UI의 설계 철학이 보인다.

asChild를 사용하지 않으면 Radix는 기본 HTML 요소를 렌더링한다:

tsx
// <button> 태그가 렌더링됨
<Dialog.Trigger>열기</Dialog.Trigger>

하지만 이미 스타일이 적용된 커스텀 버튼 컴포넌트를 사용하고 싶다면?

tsx
// asChild로 자식 요소에 Radix의 동작을 위임
<Dialog.Trigger asChild>
  <Button variant="primary" size="lg">열기</Button>
</Dialog.Trigger>

asChild를 사용하면 Radix는 자체 요소를 렌더링하지 않고, 자식 요소에 필요한 props(이벤트 핸들러, aria 속성 등)를 전달(merge)한다. 내부적으로는 @radix-ui/react-slot 패키지의 Slot 컴포넌트가 이 동작을 처리한다.

tsx
// Slot의 동작 원리 (단순화)
function Slot({ children, ...slotProps }) {
  // 자식 요소에 slotProps를 병합해서 렌더링
  return cloneElement(children, mergeProps(slotProps, children.props));
}

주의할 점은 asChild를 사용할 때 자식이 반드시 하나여야 한다는 것이다. 여러 자식을 넘기면 동작하지 않는다.

tsx
// ❌ 잘못된 사용
<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-*)을 자동으로 설정한다.

tsx
<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에 연결

키보드 내비게이션

모든 컴포넌트가 키보드로 완전히 조작 가능하다.

컴포넌트키보드 동작
DialogEscape로 닫기, Tab으로 내부 포커스 이동
DropdownMenu화살표 키로 항목 이동, Enter로 선택, Escape로 닫기
Tabs화살표 키로 탭 전환, Home/End로 처음/마지막 탭
Accordion화살표 키로 항목 이동, Space/Enter로 토글
Select화살표 키로 옵션 이동, 문자 입력으로 검색

포커스 관리

Dialog나 DropdownMenu가 열릴 때 포커스를 자동으로 내부로 이동하고, 닫힐 때 트리거로 되돌린다. Dialog에서는 포커스 트랩(focus trap)이 자동 적용되어 Tab 키를 눌러도 Dialog 바깥으로 포커스가 나가지 않는다.

tsx
<Dialog.Content
  onOpenAutoFocus={(e) => {
    // 기본 포커스 동작 변경 가능
    e.preventDefault();
    emailInputRef.current?.focus();
  }}
  onCloseAutoFocus={(e) => {
    // 닫힐 때 포커스 대상 변경
    e.preventDefault();
    triggerRef.current?.focus();
  }}
>

제어/비제어 컴포넌트

Radix의 모든 상태 관리 컴포넌트는 제어(controlled)비제어(uncontrolled) 모드를 모두 지원한다.

비제어 모드

상태를 Radix가 내부적으로 관리한다. 간단한 경우에 적합하다.

tsx
// 비제어 - 기본값만 설정하면 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>

제어 모드

상태를 외부에서 직접 관리한다. 열기/닫기 타이밍을 제어하거나, 상태에 따라 다른 로직을 실행해야 할 때 사용한다.

tsx
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 트리 최상위에 렌더링한다.

tsx
<Dialog.Portal>
  <Dialog.Overlay />
  <Dialog.Content>...</Dialog.Content>
</Dialog.Portal>

Portal이 필요한 이유는 CSS overflow: hidden이나 z-index 스태킹 컨텍스트 문제 때문이다. 부모 요소에 overflow: hidden이 설정되어 있으면 자식 요소가 잘리는데, Portal로 body 하단에 렌더링하면 이 문제를 피할 수 있다.

Portal의 렌더링 대상을 변경할 수도 있다:

tsx
<Dialog.Portal container={customContainerRef.current}>
  ...
</Dialog.Portal>

애니메이션 적용

Radix 컴포넌트는 data-state 속성을 통해 현재 상태를 CSS에 노출한다. 이를 활용해서 열기/닫기 애니메이션을 적용할 수 있다.

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-* 변형을 사용하면 더 간결하다:

tsx
<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에 커스텀 애니메이션을 정의한다:

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에 마운트 상태를 유지하고, 직접 보이기/숨기기를 제어한다.

tsx
<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와 조합하는 방법도 있다:

tsx
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 패키지와 다르게 소스 코드를 프로젝트에 직접 복사하는 방식이다.

bash
npx shadcn@latest add dialog

이 명령을 실행하면 components/ui/dialog.tsx 파일이 프로젝트에 생성된다. 의존성이 아니라 소스 코드이므로 자유롭게 수정할 수 있다.

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 컴포넌트가 내장되어 있다.

tsx
// 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을 사용한다.

tsx
// React Aria는 Hook 기반
const { buttonProps } = useButton(props, ref);
return <button {...buttonProps} ref={ref}>{children}</button>;
비교Radix UIHeadless UIReact Aria
컴포넌트 수30+~1040+
API 스타일Compound ComponentCompound ComponentHooks
프레임워크ReactReact, VueReact
접근성 수준높음높음매우 높음
생태계shadcn/uiTailwind UI (유료)Spectrum (Adobe)
번들 크기작음작음비교적 큼

Radix UI가 가장 널리 사용되는 이유는 컴포넌트 수가 충분하고, API가 직관적이며, shadcn/ui라는 강력한 생태계가 있기 때문이다.


실전 팁

1. Provider 설정

Tooltip은 TooltipProvider로 감싸야 한다. 보통 앱 루트에 한 번만 설정한다.

tsx
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를 주의해야 한다.

tsx
<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를 가장 실용적으로 사용하는 방법이다

관련 문서