UI 라이브러리
asChild 패턴이다. <Dialog.Trigger asChild><MyButton/></Dialog.Trigger>처럼 쓰면 Radix가 여분의 DOM을 만들지 않고 접근성 속성만 내 컴포넌트에 얹어준다. 이 아이디어 하나로 "라이브러리가 마크업을 강제하지 않는" 완전한 커스터마이징이 가능해졌고, 이 위에 Tailwind로 스타일만 입힌 것이 바로 shadcn/ui다.
radix-ui/primitives · 라이선스 MIT(Copyright 2022-present WorkOS) · pnpm 워크스페이스 모노레포 · packages/react 59개 + packages/core 3개 패키지 · 통합 패키지 radix-ui v1.6.7 · 개별 패키지 예: @radix-ui/react-dialog v1.1.23 · React 16.8~19 지원 · 최신 커밋 df8f89a, 2026-07-28)
Radix Primitives는 완성된 디자인 시스템이 아니다. Bootstrap이나 Material UI처럼 열면 예쁜 버튼이 나오는 라이브러리가 아니라, "버튼처럼 보이지 않는 버튼"을 준다. 겉모습은 아무 스타일도 없는 HTML 요소와 똑같지만, 그 속에는 키보드로 Tab·화살표·Esc를 눌렀을 때 어떻게 반응해야 하는지, 스크린리더에게 지금 열려 있는지 닫혀 있는지 어떻게 알려야 하는지가 전부 구현되어 있다.
구성은 Dialog.Root·Dialog.Trigger·Dialog.Content처럼 여러 개의 작은 컴포넌트를 조합(compose)하는 방식이다. 30개가 넘는 컴포넌트 패키지(아코디언·다이얼로그·드롭다운·팝오버·셀렉트·슬라이더·툴팁·툴바 등)와, 그 컴포넌트들이 공통으로 쓰는 유틸리티 패키지(포털·포커스 관리·컨텍스트·충돌 감지 등)로 나뉘어 있다.
완제품 소파(부트스트랩류 컴포넌트)를 사면 색과 모양이 이미 정해져 있어 내 방(디자인)에 안 어울릴 수 있다. Radix는 그 대신 이미 짜맞춰지고 안전 검사(접근성)까지 끝난 부품 세트를 준다. 못질(마크업)과 색칠(스타일)은 내가 하지만, "다리가 흔들리지 않는지, 서랍이 걸리는지" 같은 구조적 문제는 이미 다 해결된 채로 온다.
2023년 이후 폭발적으로 퍼진 shadcn/ui는 "라이브러리를 설치하지 말고 코드를 네 프로젝트에 복사해라"는 방식을 취했는데, 그 복사되는 컴포넌트 코드 안쪽 엔진이 대부분 Radix Primitives다. v0·Cursor·Claude 같은 AI 코딩 도구가 UI를 생성할 때도 shadcn/ui 패턴을 기본값처럼 쓰면서, 그 밑단의 Radix가 사실상 "AI가 만든 프론트엔드의 표준 접근성 엔진"이 됐다.
Tailwind CSS의 대유행으로 "스타일은 유틸리티 클래스로, 동작·접근성은 별도 라이브러리로" 나누는 방식이 표준이 됐다. Radix는 이 헤드리스 UI 카테고리에서 React Aria·Headless UI·Base UI·Ark UI와 함께 초기부터 자리를 지켜온 선두주자다.
웹 접근성 규제(ADA·유럽 EAA 등)가 강화되면서 기업들이 WAI-ARIA 준수를 사이드 프로젝트가 아니라 필수 요건으로 다루기 시작했다. 처음부터 접근성을 설계에 박아 넣은 Radix는 "나중에 접근성 감사에서 뜯어고칠" 리스크를 줄여준다.
2022년 WorkOS가 라이선스를 이어받아 관리 중이고, 최근에는 radix-ui라는 통합 패키지로 배포 방식을 정리하는 등(2025~2026년) 여전히 활발히 커밋되고 있다. 별도 CI(빌드·SSR·Chromatic 시각 회귀 테스트·npm 배포)까지 갖춘, 오래 유지되어 온 인프라성 프로젝트다.
philosophy.md에 저자들이 직접 밝힌 문제의식이 있다. 아코디언·체크박스·다이얼로그·드롭다운·셀렉트·슬라이더·툴팁 같은 UI 패턴은 이미 WAI-ARIA가 "어떻게 동작해야 하는지" 표준으로 문서화해 두었지만, 웹 플랫폼(순수 HTML)이 제공하는 구현은 아예 없거나, 기능이 부족하거나, 충분히 커스터마이징할 수 없다.
그 결과 개발자들은 매번 처음부터 만들어야 했고, 그 어려움 때문에 웹의 수많은 컴포넌트가 실제로는 접근성이 없고, 느리고, 기능이 빠진 채 배포되어 왔다. Radix는 이 문제를 "제대로 만드는 팀이 한 번만 짜고, 모두가 가져다 쓰게" 만들어 해결한다.
| 손으로 만들 때 놓치기 쉬운 것 | Radix가 대신 처리 |
|---|---|
| 키보드 탐색 | Tab·화살표·Home/End·Esc의 정확한 조합(컴포넌트별로 다름) |
| 포커스 관리 | 모달 열릴 때 포커스 가두기(trap), 닫힐 때 원래 위치로 복귀 |
| 바깥 클릭·Esc 닫기 | 레이어가 겹쳐도 "가장 위 레이어만" 정확히 닫히는 처리 |
| 스크린리더 신호 | aria-expanded·role·aria-describedby 등 자동 부여 |
| 겹치는 인스턴스 격리 | 같은 페이지에 툴팁 100개가 있어도 서로 상태 간섭 없음 |
저장소는 pnpm 워크스페이스 모노레포다(pnpm-workspace.yaml). 실제로 클론해 packages/를 열어 보면 이렇게 나뉜다.
핵심은 packages/react/radix-ui다. 이 패키지의 src/dialog.ts는 코드 전체가 단 한 줄, export * from '@radix-ui/react-dialog'다. 즉 통합 패키지 radix-ui는 새 구현이 아니라, 기존 개별 패키지 30여 개를 한 번에 설치하도록 묶은 "재수출 껍데기"다. 실제 로직·버전·변경 이력은 여전히 각 개별 패키지(@radix-ui/react-dialog 등)에 있다.
workspace:* 표기로 패키지끼리 로컬 참조를 걸어, 예를 들어 @radix-ui/react-dialog가 저장소 안의 @radix-ui/react-portal을 항상 최신 로컬 버전으로 참조하게 한다.
Radix를 이해한다는 건 결국 이 6가지 재료를 이해하는 것이다. 아래는 전부 실제 소스(packages/react/*/src/*.tsx)에서 직접 확인한 내용이다.
asChild + Slot — "내 태그로 갈아 끼우기"
모든 Radix 컴포넌트는 packages/react/primitive/src/primitive.tsx의 Primitive를 기반으로 한다. 각 HTML 태그(button·div·a…)마다 createSlot으로 만든 Slot 컴포넌트를 하나씩 갖고 있고, asChild prop이 있으면 실제 태그 대신 이 Slot을 렌더링한다.
const Primitive = NODES.reduce((primitive, node) => {
const Slot = createSlot(`Primitive.${node}`);
const Node = React.forwardRef((props, forwardedRef) => {
const { asChild, ...primitiveProps } = props;
const Comp = asChild ? Slot : node; // asChild면 진짜 태그 대신 Slot 사용
return <Comp {...primitiveProps} ref={forwardedRef} />;
});
return { ...primitive, [node]: Node };
}, {});
packages/react/slot/src/slot.tsx의 createSlot이 진짜 마법이다. Slot은 자기 자식이 정확히 하나의 유효한 React 엘리먼트인지 확인한 뒤, 그 자식을 React.cloneElement로 복제하면서 자신이 받은 props(slotProps)와 자식의 props(childProps)를 mergeProps로 병합해 자식에게 그대로 떠넘긴다. 이때 병합 규칙이 꽤 섬세하다.
function mergeProps(slotProps, childProps) {
const overrideProps = { ...childProps }; // 기본은 자식(child)이 이긴다
for (const propName in childProps) {
const isHandler = /^on[A-Z]/.test(propName);
if (isHandler) {
// onClick 등 이벤트 핸들러는 "둘 다 실행"되도록 합성(compose)
if (slotProps[propName] && childProps[propName]) {
overrideProps[propName] = (...args) => {
childProps[propName](...args);
slotProps[propName](...args);
};
}
} else if (propName === 'style') {
overrideProps[propName] = { ...slotProps[propName], ...childProps[propName] }; // style은 병합
} else if (propName === 'className') {
overrideProps[propName] = [slotProps[propName], childProps[propName]].filter(Boolean).join(' '); // className은 이어붙임
}
}
return { ...slotProps, ...overrideProps };
}
즉 이벤트 핸들러는 합쳐서 둘 다 실행, style/className은 병합, 나머지 일반 prop은 자식이 우선이라는 규칙이다. 그리고 ref는 useComposedRefs로 합성해, Radix 내부가 관리하는 ref와 사용자가 자식 컴포넌트에 건 ref가 동시에 같은 DOM 노드를 가리키게 만든다. 자식이 없거나(falsy) 여러 개거나 엘리먼트가 아니면 명확한 에러를 던진다(조용히 무시하지 않음).
인형 탈(Slot)을 쓴 안내원이라 생각하면 된다. Radix는 원래 "버튼 인형 탈"을 쓰고 나오지만, asChild를 주면 "내가 준비한 인형 탈(내 컴포넌트)"을 대신 씌운다. 안내원(접근성 로직·이벤트·ref)은 그대로 안에 남아 있고, 겉모습만 바뀐다. 그래서 최종 DOM에는 <button>도 <div>도 아닌, 내가 준 컴포넌트가 렌더링한 태그 하나만 남는다 — 여분의 wrapper가 생기지 않는다.
모든 컴포넌트는 여러 조각으로 나뉜다. 예컨대 Dialog는 Dialog(=Root, 상태 보관)·Trigger·Portal·Overlay·Content·Title·Description·Close로 구성된다. 이 방식은 "조합이 설정보다 낫다(composition over configuration)"는 philosophy.md의 원칙을 그대로 코드로 옮긴 것이다 — 옵션 하나를 추가하는 대신, 필요한 조각만 가져다 쓰거나 위치를 바꿔 조립한다.
createContextScope — 중첩 인스턴스 격리
packages/react/context/src/create-context.tsx에는 일반 createContext 외에 createContextScope가 있다. 문제는 이렇다 — 예를 들어 Tooltip 안에 다른 라이브러리가 Tooltip을 또 렌더링하면(중첩), 평범한 React Context로는 안쪽 인스턴스가 바깥 인스턴스의 상태까지 읽어버릴 위험이 있다. createContextScope는 각 컴포넌트 인스턴스마다 __scopeTooltip 같은 이름의 prop으로 전용 Context 배열을 전달할 수 있게 해, 인스턴스별로 완전히 격리된 상태를 갖게 한다. 또한 다른 컴포넌트의 스코프(createCollectionScope 등)를 의존성으로 합성할 수 있어, RovingFocusGroup처럼 내부적으로 다른 프리미티브를 쓰는 컴포넌트도 스코프 충돌 없이 동작한다.
Presence — "사라지는 애니메이션이 끝날 때까지 언마운트를 미룬다"
present=false가 되는 순간 바로 DOM에서 지워버리면 CSS exit 애니메이션이 재생될 시간이 없다. packages/react/presence/src/presence.tsx는 유한 상태 머신(mounted → unmountSuspended → unmounted)으로 이 문제를 푼다. present가 false가 되면 곧바로 언마운트하지 않고, 실제 animationName이 바뀌었는지 계산 스타일을 읽어 "애니메이션이 실행 중"이라고 판단되면 unmountSuspended 상태로 대기하다가, 브라우저의 animationend 이벤트를 받은 뒤에야 실제로 언마운트한다.
Portal — document.body로 그려서 겹침·잘림을 피한다
packages/react/portal/src/portal.tsx는 매우 짧다. useLayoutEffect에서 mounted를 true로 바꾼 뒤에야 ReactDOM.createPortal로 실제 DOM 트리(기본은 document.body, container prop으로 다른 요소 지정 가능)에 콘텐츠를 그린다. Dialog·Popover·Tooltip처럼 overflow:hidden인 부모 요소 밖으로 튀어나가야 하는 UI가 전부 이 Portal 위에 얹힌다.
FocusScope · DismissableLayer · RovingFocusGroup — 상호작용 3종 유틸| 유틸 | 역할 |
|---|---|
| FocusScope | 모달 열릴 때 포커스를 그 안에 가두고(trap), 닫히면 이전 포커스 위치로 되돌린다. 중첩된 non-modal 레이어를 "같은 스코프의 가지(branch)"로 등록하는 FocusScopeBranchRegistry도 제공(예: Dialog 위에 뜬 non-modal Popover가 트랩을 방해하지 않도록) |
| DismissableLayer | 바깥 클릭·Esc 키로 레이어를 닫는 로직. 레이어가 여러 겹 쌓여도 가장 위(가장 나중에 열린) 레이어만 반응하도록 레이어 스택을 관리한다 |
| RovingFocusGroup | createCollection으로 그룹 내 아이템들을 수집한 뒤, 화살표 키로 이동할 때 현재 항목만 tabIndex=0, 나머지는 -1로 바꿔가며(roving tabindex) Tab 키 하나로는 그룹 전체를 건너뛰게 만든다. Tabs·Toolbar·RadioGroup·Menu류가 공유해서 쓴다 |
data-state로 상태를 "노출"한다
philosophy.md는 상태를 불리언 대신 열거형 문자열로 표현하고 data-state 속성으로 DOM에 그대로 노출하라고 규정한다. 예컨대 Accordion 아이템은 data-state="open"/"closed"를 갖는데, 이는 ① CSS 선택자로 스타일을 걸 수 있게 하고 ② role·aria-* 속성과 함께 스크린리더가 정확한 상태를 읽게 하는 이중 역할을 한다. 새로운 컴포넌트를 설계할 땐 먼저 WAI-ARIA 문서화 패턴을 찾고, 없으면 잘 검증된 네이티브 구현(운영체제 UI 등)을 참고한다고 명시되어 있다.
philosophy.md의 "Composable" 원칙: 컴포넌트 하나는 DOM 노드 하나만(또는 아예 안) 렌더링하는 것을 목표로 한다. 이 원칙이 있어야 asChild로 태그를 갈아 끼워도 여분의 wrapper가 남지 않고, ref도 "정확히 그 노드"를 가리킬 수 있다.
| 영역 | 내용 |
|---|---|
| 런타임 대상 | React 16.8 ~ 19(peerDependencies에 ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc 명시). react-dom 동일 범위 |
| 패키지 매니저 | pnpm 전용(AGENTS.md에 "pnpm only" 명시). npm/yarn 사용 흔적 없음 |
| 빌드 | 내부 패키지 @repo/builder(radix-build CLI)가 tsup(esbuild 기반)을 감싸 각 패키지를 CJS/ESM 이중 출력(dist/index.js + index.mjs)으로 빌드. Turborepo는 쓰지 않고 pnpm -r --parallel로 병렬 빌드 |
| 린트/포맷 | oxlint + oxfmt(Rust 기반 초고속 린터/포매터). ESLint/Prettier 아님 — 비교적 최근 전환된 스택 |
| 단위 테스트 | Vitest + @testing-library/react + jsdom (테스트 파일 38개) |
| E2E 테스트 | Playwright — dialog·popover·select·dropdown-menu·context-menu·menubar·form·toast·toggle-group·scroll-area 등 12개 시나리오 + @axe-core/playwright로 접근성 자동 검사 |
| 시각 회귀 | Chromatic + Storybook — UI 변화를 스크린샷으로 비교(CI에 chromatic.yml 워크플로 존재) |
| 버전·배포 | Changesets로 패키지별 독립 버전 관리(.changeset/*.md) → publish.yml에서 npm에 개별 배포 |
| 핵심 런타임 의존 | react-remove-scroll(스크롤 잠금) · aria-hidden(배경 콘텐츠를 스크린리더에서 숨김) — Dialog·AlertDialog 등이 사용 |
통합 패키지 radix-ui는 자체 버전(예: 1.6.7)을 갖지만, 이는 "이 재수출 껍데기가 몇 번째 릴리스인가"일 뿐이다. 실제로 동작하는 Dialog의 로직·버그 수정 이력은 @radix-ui/react-dialog(예: 1.1.23)라는 완전히 별개의 버전 번호를 갖는 개별 패키지에 있다. 릴리스 노트를 볼 때는 반드시 개별 패키지의 CHANGELOG를 확인해야 한다.
필요한 컴포넌트만 개별로 설치하거나(예전 방식·여전히 유효), 전부 한 번에 설치하는(통합 패키지) 두 갈래가 있다.
# 방법 1 — 컴포넌트별 개별 설치(번들 크기를 세밀하게 관리하고 싶을 때)
npm install @radix-ui/react-dialog @radix-ui/react-tooltip
# 방법 2 — 통합 패키지 하나로 전체 설치(현재 공식 문서가 권장하는 방식)
npm install radix-ui
| 항목 | 요구사항 |
|---|---|
| 런타임 | React 16.8 이상(Hooks 필요), react-dom |
| 타입스크립트 | 필수는 아니지만 저장소 자체는 TypeScript 전용으로 개발됨(@types/react는 선택적 peer dep) |
| 스타일링 도구 | 없음(무관) — Tailwind·vanilla-extract·CSS Modules·styled-components 등 아무 도구나 위에 얹을 수 있음 |
| SSR | 지원됨(apps/ssr-testing으로 검증). useId·useIsHydrated 같은 유틸로 하이드레이션 불일치를 방지 |
| 번들러 | ESM/CJS 이중 배포라 Vite·webpack·Next.js 등 대부분의 번들러에서 별도 설정 없이 동작 |
| 기여자용 로컬 환경 | 저장소를 직접 빌드하려면 pnpm 필수(npm install -g pnpm → pnpm install → pnpm build / pnpm storybook) |
가장 흔한 예로 다이얼로그(모달)를 조립하는 전형적인 코드다. 문서가 아니라 소스 코드의 실제 export 이름(Dialog, Trigger, Portal, Overlay, Content, Close)을 그대로 썼다.
import { Dialog } from 'radix-ui'; // 통합 패키지에서 네임스페이스로 가져오기
function EditProfile() {
return (
<Dialog.Root>
{/* asChild: Radix의 기본 button 대신 내가 만든 버튼을 그대로 사용 */}
<Dialog.Trigger asChild>
<MyStyledButton>프로필 수정</MyStyledButton>
</Dialog.Trigger>
{/* Portal: document.body로 이동해서 그려짐(overflow:hidden 문제 회피) */}
<Dialog.Portal>
<Dialog.Overlay className="overlay" />
<Dialog.Content className="content">
<Dialog.Title>프로필 수정</Dialog.Title>
<Dialog.Description>
변경 사항은 저장 버튼을 눌러야 반영됩니다.
</Dialog.Description>
{/* 여기 안에서는 FocusScope가 Tab 포커스를 가둔다 */}
<input placeholder="이름" />
<Dialog.Close asChild>
<MyStyledButton>닫기</MyStyledButton>
</Dialog.Close>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
이 20줄 안에서 실제로 일어나는 일을 순서대로 풀면:
Dialog.Root가 useControllableState로 open 상태를 만들고 createContextScope로 만든 Context에 담아 자식들에게 내려보낸다.Dialog.Trigger가 asChild를 받아 Slot으로 바뀌고, onClick을 MyStyledButton의 기존 onClick과 합성해 클릭 시 open을 토글한다.open이 true가 되면 Dialog.Content를 감싼 Presence가 mounted 상태로 전이하고, Dialog.Portal이 이를 document.body에 그린다.Content 내부의 FocusScope가 포커스를 안에 가두고, DismissableLayer가 바깥 클릭·Esc를 감지해 onOpenChange(false)를 부른다.open이 false가 되면 Presence가 CSS exit 애니메이션이 끝날 때까지 기다렸다가 실제로 언마운트한다.이 흐름은 연극 무대와 비슷하다. Root는 대본(상태)을 쥔 연출자, Trigger는 막을 여는 스위치, Portal은 배우를 메인 무대(document.body)로 옮기는 장치, FocusScope는 관객의 시선(포커스)을 무대 안에 붙잡아 두는 조명, Presence는 배우가 퇴장 연기를 끝낼 때까지 기다렸다가 무대를 접는 스태프다.
Radix는 시각적으로 아무 스타일도 주지 않는다. 처음 써 본 사람이 "왜 버튼이 그냥 밋밋한 텍스트로 보이지?"라며 당황하는 게 가장 흔한 첫 반응이다. CSS(또는 Tailwind, 또는 shadcn/ui 같은 스타일 입혀진 래퍼)를 직접 얹어야 완제품이 된다.
asChild는 "자식이 정확히 하나"일 때만 동작한다slot.tsx 코드를 보면 자식이 텍스트거나, 2개 이상이거나, 유효한 엘리먼트가 아니면 명시적으로 에러를 던진다(조용히 무시하지 않음). <Trigger asChild>텍스트{icon}</Trigger>처럼 여러 노드를 넣으면 런타임 에러가 난다 — 반드시 하나의 래퍼 엘리먼트로 감싸야 한다.
4장에서 확인했듯 radix-ui 패키지의 dialog.ts는 export * from '@radix-ui/react-dialog' 한 줄뿐이다. 내부 로직을 새로 통합한 게 아니라 기존 30여 개 패키지를 한 번에 설치하게 묶은 것이다. "통합 버전이라 더 최적화됐다"는 식의 오해에 주의.
클론 시점 기준 최신 커밋이 바로 "slot: Add customizable mergeProps"라는 기능을 되돌리는(revert) PR(#4084, 2026-07-28)이었다. 즉 mergeProps를 사용자가 커스터마이징할 수 있게 하는 기능이 추가됐다가 문제가 있어 바로 되돌려졌다 — 겉보기엔 안정적인 저장소지만 핵심부(Slot)는 지금도 실험 중임을 보여주는 사례다. 마이너 버전만 보고 "완전히 굳어진 API"라 단정하면 안 된다.
공식 문서(radix-ui.com/primitives/docs)는 이 저장소와 별도 사이트에서 관리된다. 특정 prop·동작이 문서와 실제 설치된 버전에서 다르게 느껴진다면, 문서 대신 node_modules/@radix-ui/react-*/dist의 실제 코드나 각 패키지의 CHANGELOG.md를 확인하는 게 가장 정확하다.
| 라이브러리 | 특징 · Radix와 다른 점 |
|---|---|
| Headless UI | Tailwind Labs 제작. Tailwind CSS와 짝을 이루는 용도로 시작. React/Vue 지원하지만 컴포넌트 종류가 Radix보다 적음(핵심 패턴 위주). Tailwind 생태계 안에서 가장 자연스럽게 어울림 |
| React Aria (Adobe) | 컴포넌트가 아니라 훅(hook) 단위로 접근성 로직을 제공(예: useButton, useDialog). 국제화(i18n)·날짜/시간 포맷 지원이 매우 강력하다. Radix보다 학습 곡선은 높지만 커스터마이징 자유도는 더 높다 |
| Base UI | MUI(구 Material-UI) 팀과 옛 Radix·Floating UI 핵심 기여자들이 합류해 만든 비교적 신생 라이브러리. Radix의 정신적 후계자로 불리며, 겹침 위치 계산에 Floating UI를 더 깊이 통합 |
| Ark UI | Chakra UI 팀 제작. 상태 머신 엔진 Zag.js 위에서 동작해 React·Vue·Solid·Svelte를 한 로직으로 지원하는 게 최대 차별점(Radix는 React 전용) |
popper 패키지도 이 계산 로직을 내부적으로 사용한다.React 전용 프로젝트이고, shadcn/ui처럼 컴포넌트 단위 조립 API(Root/Trigger/Content)가 익숙하며, "가장 많은 사람이 쓰고 예제가 가장 많은" 선택지를 원할 때 Radix가 안전하다. 반대로 멀티 프레임워크(Vue·Svelte)를 지원해야 하거나 국제화가 핵심 요구사항이면 Ark UI·React Aria 쪽이 더 적합할 수 있다.
Radix Primitives는 완제품 UI 킷이 아니라 접근성 엔진이다. 30여 개 컴포넌트 패키지가 공통으로 asChild+Slot(태그 갈아 끼우기), createContextScope(중첩 인스턴스 격리), Presence(퇴장 애니메이션 대기), Portal·FocusScope·DismissableLayer·RovingFocusGroup(레이어·포커스·키보드 탐색)이라는 소수의 정밀한 유틸리티 위에 조립되어 있다. 통합 패키지 radix-ui는 그 개별 패키지들을 한 번에 설치하는 재수출 껍데기일 뿐, 실제 버전과 변경 이력의 진실원은 여전히 각 개별 패키지다.
Figma 디자인이 이미 있고, 그 디자인대로 다이얼로그·드롭다운·툴팁을 짜야 하는데 키보드 탐색·포커스 트랩까지 손으로 만들 여유가 없는 경우. shadcn/ui를 이미 쓰고 있어서 그 내부 동작 원리를 이해하고 커스터마이징하고 싶은 경우.
당장 스타일링 작업 없이 완제품이 필요하다면 Radix 위에 스타일을 이미 입혀 둔 shadcn/ui·Radix Themes 쪽이 낫다. Vue·Svelte·Solid를 쓴다면 Ark UI가 더 맞는 선택이다.
아래 경로와 명령은 모두 Radix 저장소 루트 기준이다. 의존성이 설치된 별도의 학습용 사본에서 진행하고, 브라우저 테스트에는 Playwright용 Chromium도 준비한다. 실행 명령의 정의는 루트 package.json, 예제 탐색 범위는 apps/storybook/.storybook/main.ts에서 확인할 수 있다.
목표: 다이얼로그의 상태 관리와 포커스 이동을 화면에서 확인하고 소스와 연결한다.
apps/storybook/stories/dialog.stories.tsx에서 Styled와 Controlled를 비교한다. 후자에서 open과 onOpenChange가 어떻게 연결되는지 표시한다.Components/Dialog의 Styled를 연다. Tab으로 열기 버튼에 도달해 Enter 또는 Space로 열고, Esc로 닫은 뒤 포커스가 돌아오는 위치를 기록한다.FocusTrap 예제에서 Tab과 Shift+Tab을 반복한다. 개발자 도구로 트리거의 aria-expanded와 data-state, 콘텐츠의 role과 제목 연결을 확인한다.packages/react/dialog/src/dialog.tsx와 packages/react/use-controllable-state/src/use-controllable-state.tsx를 읽고, 비제어 방식의 내부 상태와 제어 방식의 부모 상태를 각각 찾아본다.pnpm storybook
완료 기준: 열기 전·열린 뒤·닫힌 뒤의 상태와 포커스 위치를 표로 남긴다. Controlled에서 부모가 새 open 값을 전달해야 화면이 바뀌는 이유를 설명할 수 있어야 한다.
목표: asChild가 DOM을 추가하지 않으면서 속성·이벤트·참조를 전달하는 계약을 검증한다.
packages/react/primitive/src/primitive.tsx에서 asChild 분기를 찾고, packages/react/slot/src/slot.tsx의 mergeProps까지 따라간다.packages/react/slot/src/slot.test.tsx에서 부모와 자식 양쪽에 onClick을 전달하는 사례를 읽는다. 호출 기록을 배열에 넣으면 어떤 순서가 될지 실행 전에 적는다.<button> 자식을 둔 사례를 추가한다. 양쪽 클릭 처리기의 실행 순서, 양쪽 ref가 가리키는 DOM의 일치, 부모와 자식에 서로 다른 title을 줬을 때의 최종 값을 검사한다.packages/react/compose-refs/src/compose-refs.tsx를 읽고 참조 합성 경로를 확인한 뒤 아래 명령으로 해당 파일만 실행한다.pnpm test --run packages/react/slot/src/slot.test.tsx
완료 기준: 자식 클릭 처리기 다음에 Slot 클릭 처리기가 각각 한 번 실행되고, 두 ref는 같은 버튼을 받으며, 최종 title은 자식 값이라는 검사가 통과한다. 렌더링 결과에 Slot 전용 래퍼가 없는지도 확인한다.
목표: 화살표로 이동한 항목과 실제 선택된 항목이 언제 같고 언제 다른지 이해한다.
apps/storybook/stories/tabs.stories.tsx의 Styled를 연다. 가로 자동 활성화 예제는 좌우 화살표로, 세로 수동 활성화 예제는 위아래 화살표로 조작한다.tabIndex와 aria-selected가 어떻게 달라지는지도 확인한다.packages/react/tabs/src/tabs.tsx의 onFocus·onKeyDown과 packages/react/roving-focus/src/roving-focus-group.tsx의 이동 처리를 찾아 관찰 결과와 연결한다.packages/react/tabs/src/tabs.test.tsx를 실행한다. 이 파일의 기존 검사가 다루는 상황과 이번에 브라우저에서 관찰한 상황을 구분해 적는다.pnpm storybook
# 다른 터미널에서 실행
pnpm test --run packages/react/tabs/src/tabs.test.tsx
완료 기준: 자동·수동 모드별로 누른 키, 포커스 대상, 선택된 값, 표시된 패널을 비교한 표를 만든다. 수동 모드에서 화살표 이동만으로 선택이 바뀌지 않는 이유를 소스에서 짚을 수 있어야 한다.
목표: 논리적으로 닫힌 상태와 실제 언마운트 사이의 시간차를 설명하고 브라우저 테스트로 확인한다.
apps/storybook/stories/dialog.stories.tsx의 Animated와 apps/storybook/stories/dialog.stories.module.css의 닫힘 선택자를 함께 읽는다. 개발자 도구에서 애니메이션 재생 속도를 낮추고 닫는 동안 data-state="closed"인 콘텐츠가 남는지 관찰한다.packages/react/presence/src/presence.tsx와 packages/react/presence/src/use-state-machine.tsx에서 퇴장 애니메이션이 있는 경우와 없는 경우의 전이를 각각 그린다.e2e/dialog.spec.ts의 키보드 열기·닫기 검사와 e2e/helpers.ts의 visitStory를 읽는다. 학습용 사본에 visitStory(page, 'dialog--animated')로 예제를 열고, 닫은 뒤 콘텐츠가 최종적으로 제거되며 트리거로 포커스가 복귀하는 회귀 테스트를 추가한다.playwright.config.ts의 webServer가 로컬 예제 서버를 준비하므로 별도로 서버를 띄울 필요는 없다.pnpm e2e e2e/dialog.spec.ts
완료 기준: 닫힘 상태에서 DOM이 잠시 유지되는 관찰 기록과 브라우저 테스트 결과를 남긴다. 자동 검사는 고정 시간 대기 대신 DOM 제거와 포커스 복귀를 기다리게 하고, 애니메이션이 없으면 즉시 제거되는 분기도 설명한다.
매주 예제를 먼저 조작하고, 해당 동작을 만드는 소스를 읽은 뒤 결과물을 남긴다. 버전 번호를 외우기보다 상태·DOM·이벤트가 연결되는 경로를 설명하는 데 집중한다.
| 주차 | 배울 개념 | 이 레포에서 읽을 곳 | 결과물 |
|---|---|---|---|
| 1주차 | 헤드리스 UI, 모노레포, 워크스페이스 참조와 재수출 | philosophy.md, pnpm-workspace.yaml, 루트 package.json, packages/react/radix-ui/src/dialog.ts, packages/react/dialog/package.json |
통합 패키지에서 개별 구현까지 이어지는 의존성 지도와 주요 실행 명령 목록 |
| 2주차 | 제어·비제어 상태, 합성 컴포넌트, Context 범위 | packages/react/dialog/src/dialog.tsx, packages/react/use-controllable-state/src/use-controllable-state.tsx, packages/react/context/src/create-context.tsx |
과제 ① 기록과 함께 Root·Trigger·Content 사이의 상태 전달 그림 |
| 3주차 | 요소 합성, 이벤트 병합, DOM ref 전달 | packages/react/primitive/src/primitive.tsx, packages/react/slot/src/slot.tsx, packages/react/compose-refs/src/compose-refs.tsx, packages/react/slot/src/slot.test.tsx |
과제 ② 테스트와 속성 충돌·이벤트 순서·참조 전달을 정리한 계약표 |
| 4주차 | 키보드 탐색, 포커스와 선택의 차이, WAI-ARIA 속성 | apps/storybook/stories/tabs.stories.tsx, packages/react/tabs/src/tabs.tsx, packages/react/roving-focus/src/roving-focus-group.tsx, packages/react/collection/src/ |
과제 ③ 비교표와 비활성 항목·탐색 방향을 포함한 키보드 점검표 |
| 5주차 | 포털, 포커스 트랩, 바깥 상호작용, 퇴장 상태 머신 | packages/react/portal/src/portal.tsx, packages/react/focus-scope/src/focus-scope.tsx, packages/react/dismissable-layer/src/dismissable-layer.tsx, packages/react/presence/src/ |
과제 ④의 상태 전이도와 열기·닫기·언마운트 순서도 |
| 6주차 | 단위 테스트와 실제 브라우저 테스트의 역할, 서버 렌더링과 하이드레이션 | vitest.config.mts, scripts/setup-tests.ts, playwright.config.ts, e2e/dialog.spec.ts, apps/ssr-testing/README.md, apps/ssr-testing/app/dialog/page.tsx |
각 동작을 어느 환경에서 검사할지 정한 검증표와 SSR 예제의 초기 화면·상호작용 관찰 기록 |
pnpm dev:ssr로 SSR 실험 앱을 실행해 Dialog 예제를 확인한다. 단위 테스트 통과 여부, 실제 브라우저의 키보드 동작, 하이드레이션 뒤의 상호작용을 각각 기록하면 검사 범위의 빈틈을 찾기 쉽다.
pnpm-workspace.yaml로 작업 범위를 묶고 workspace:*로 내부 패키지를 참조한다.Root·Trigger·Content는 배치를 나눠도 Context를 통해 함께 동작한다.primitive.tsx는 이 옵션에 따라 기본 태그와 Slot 중 사용할 대상을 고른다.packages/react/slot/src/slot.tsx에서 요소 복제와 병합을 담당하므로, asChild를 써도 Slot 자체의 DOM 래퍼가 추가되지 않는다.composeRefs와 useComposedRefs는 사용자의 ref와 Radix 내부 ref가 같은 요소를 다루도록 연결한다.open·defaultOpen·onOpenChange는 useControllableState를 통해 이 두 방식을 지원한다.createContextScope와 __scopeDialog 같은 내부 전달값으로 합성 과정에서 사용할 상태 연결을 구분한다.document.body를 사용하며 container로 다른 위치를 지정할 수 있다.FocusScope를 사용해 열린 동안 포커스를 관리하고 닫힐 때 트리거로 복귀하도록 연결한다.onOpenChange(false)로 연결한다.RovingFocusGroup은 Tabs 등의 항목 수집과 키보드 이동을 맡는다.role="tab"·aria-selected 같은 속성을 키보드 동작과 함께 구현한다.open·closed, Tabs의 active·inactive처럼 값이 다르며 접근성 의미는 별도의 role·aria-*가 전달한다.Presence는 이를 사용해 CSS 퇴장 애니메이션이 있으면 언마운트를 미루고, 종료 이벤트를 받은 뒤 DOM에서 제거한다.