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가 더 맞는 선택이다.