Radix UI — unstyled accessible 的 React 组件原语库
已复核Radix UI Primitives 是一套无展示样式、按 WAI-ARIA pattern 实现行为的 React 原语。日常类比:装修毛坯房——水电、承重和防火门先按规范做好,你只负责刷漆和家具。
固定 1.6.7 同时提供两条入口:继续按 @radix-ui/react-dialog 这种分包安装;或装 umbrella 包 radix-ui,再 import { Dialog } from "radix-ui"。行为合同在两边相同,只是分发粒度不同。shadcn-ui 的 new-york-v4 模板已经改走后一种。
不理解这套原语,下面这些事都对不上号:
- 为什么 shadcn 能把“复制一段组件源码”做成主流分发,却不必自己重写焦点和 Esc
- 为什么 Dialog 看起来像四个组件,实际是 Presence / Portal / FocusScope / DismissableLayer 的接力
- 为什么同一个
openAPI 既能让父组件接管,也能让原语自己记状态 - 为什么
asChild能换掉默认<button>,却不会叠出双层 DOM
主链可以拆成五步,Dialog 是最完整的样本:
-
Root 用
useControllableState决定状态归属:open !== undefined走受控,否则用defaultOpen。受控路径里setOpen立刻调onOpenChange;非受控路径先useState,再在 effect 里通知。DEV 下中途切换受控/非受控会警告。 -
Portal + Presence 决定挂载时机:打开时把内容逃出父级 overflow;关闭时 Presence 的 mounted / unmountSuspended / unmounted 状态机等 CSS 退场动画结束再卸节点。
data-state="open|closed"就是给这段动画看的。 -
FocusScope 管焦点:modal 打开时 trap + loop,关闭后把焦点还回 Trigger。
-
DismissableLayer 管退出:Esc、pointerdown-outside、focus-outside。Overlay 会登记成
dismissableSurfaces,即使你在 Overlay 上stopPropagation,点遮罩仍能关。 -
Slot /
asChild换 root 节点:Primitive.button默认渲染<button>;asChild时createSlot把事件、ARIA 和 ref merge 到唯一子元素。多个有效子节点会直接 throw,不再静默包一层。
createContextScope 给每个实例独立的 __scope*,嵌套 Dialog/Popover 才不会共用同一份 open 状态。philosophy.md 把“一组件一 DOM、零展示样式、有限 data-state”写成硬原则,后面这些零件都从这里推出来。
案例 1:最小 Dialog(分包入口)
Section titled “案例 1:最小 Dialog(分包入口)”import * as Dialog from "@radix-ui/react-dialog";
export function Modal() { return ( <Dialog.Root> <Dialog.Trigger>打开</Dialog.Trigger> <Dialog.Portal> <Dialog.Overlay /> <Dialog.Content> <Dialog.Title>提示</Dialog.Title> <Dialog.Description>行为已经由原语接好。</Dialog.Description> <Dialog.Close>关闭</Dialog.Close> </Dialog.Content> </Dialog.Portal> </Dialog.Root> );}Trigger 自带 aria-haspopup="dialog";Content 的 aria-labelledby 只在 Title 真的挂载时出现。固定版本里 WarningProvider 已是 noop,缺 Title 不会再靠 dev console.warn 兜底。
案例 2:umbrella 包 + asChild
Section titled “案例 2:umbrella 包 + asChild”import { Dialog } from "radix-ui";
<Dialog.Trigger asChild> <MyButton variant="primary">打开</MyButton></Dialog.Trigger>渲染结果是 MyButton 自己,不是 <button><MyButton/></button>。onClick、aria-expanded 和 ref 都 merge 到你的按钮上。
案例 3:自己接受控双模
Section titled “案例 3:自己接受控双模”import { useControllableState } from "@radix-ui/react-use-controllable-state";
export function MyTabs({ value, defaultValue, onValueChange }) { const [active, setActive] = useControllableState({ prop: value, defaultProp: defaultValue ?? "tab1", onChange: onValueChange, caller: "MyTabs", }); return <button onClick={() => setActive("tab2")}>{active}</button>;}caller 只影响 DEV 警告文案。受控时 setActive 同步进 onValueChange;非受控时要等下一轮 effect。
- 把缺 Title 当成“库会警告你”:固定 1.6.7 只是不写
aria-labelledby。a11y 仍要自己用 axe 一类工具验。 - 受控/非受控来回切:同一
setValue,受控立即拿到新值,非受控晚一帧;表单提交读 value 时会踩到这个时机差。 - 给 Slot 塞两个根节点:
createSlot会 throw,不会再默默包一层 div。 - 在 Overlay 上
stopPropagation指望挡住关闭:Overlay 已登记为 dismiss surface,点遮罩仍会 dismiss。 - 把 umbrella 包和分包版本号当成同一个数:
radix-ui@1.6.7与@radix-ui/react-dialog@1.1.23同提交但不同版本字段,升级要对包名。
适用 vs 不适用场景
Section titled “适用 vs 不适用场景”适用:
- 自建设计系统,只想买下 ARIA / 焦点 / 键盘,样式自己写
- 已经在用 shadcn-ui 或直接依赖
radix-uiumbrella 的项目 - 需要嵌套浮层、modal 滚动锁、退场动画后再卸载
不适用:
- 只要一个原生
<dialog>或简单折叠,原语树偏重 - 已锁定 MUI / Ant Design 的视觉和 token,切换等于重写样式层
- 想要 hook 返回 props、自己拼 DOM——那是 React Aria 的模型
- 需要把“某个 Dialog 一定是 N kb gzip”写成预算——本文未测 bundle
固定版本边界
Section titled “固定版本边界”- 本文绑定
radix-ui/primitives@9aebdd45...,即 annotated tag1.6.7的解引用提交。 - umbrella 包
radix-ui版本为1.6.7;同提交中@radix-ui/react-dialog为1.1.23。个别子包在 npm 上的更新版本不自动适用本文。 - peer 为 React / ReactDOM
^16.8 || ^17 || ^18 || ^19。Slot 同时兼容 React 18 的element.ref与 React 19 的props.ref。 - 本文未安装依赖、未跑 vitest / Playwright、未测 bundle 或屏幕阅读器,状态保持
UNVERIFIED。
- 无样式不是“没做完”,是把行为合同从视觉里拆出来
- 一组件一 DOM 才能让
asChild成立——允许多层 wrapper,调用方就换不了 root tag - 受控双模是 hook 级一等公民,不是每个原语各写一套 if
- 复杂浮层是零件接力:Presence 管寿命,Portal 管位置,FocusScope 管焦点,DismissableLayer 管退出
- 固定 1.6.7 里,Dialog 不写
Title,Content 还会带aria-labelledby吗?dev 模式一定会 warn 吗? - 非受控 Dialog 里调用
setOpen(true),onOpenChange是同步发生还是等 effect? <Dialog.Trigger asChild><Icon/><Label/></Dialog.Trigger>会渲染成什么?
检查点:
- 不会带
aria-labelledby;WarningProvider已是 noop,不能指望 console.warn。 - 等 effect。只有受控路径同步调
onChange。 createSlot期望单一元素子节点,这样会 throw,不会包一层。
- 官方文档:Radix Primitives
- 设计原则:仓库内
philosophy.md(绑定提交可核对) - 固定源码:radix-ui/primitives —— 本文绑定提交
9aebdd45abd447b84092ecf20f8bcd27f2398c36 - shadcn-ui —— 把这些原语变成可复制的 Tailwind 模板