起因
要做一个 a11y 合规的 dropdown menu:键盘 navigation / focus trap /
ARIA roles / Escape 关闭 / 点外面关闭 / 上下方向键循环 / Home/End 键
跳第一项末项 / 输入字符直接跳到对应项 / ...
每个细节单独写都 30-50 行 JS。完全无 bug 实施一个 dropdown 要 1 天。
不抽象怎么办——5 个组件就要 1 周。
Radix UI Primitives 把这些"a11y 行为正确" 的组件做成了 React component,
不带样式,你用 Tailwind / CSS / 任何 styling 方案套外观。
装
npm i @radix-ui/react-dropdown-menu
npm i @radix-ui/react-dialog
npm i @radix-ui/react-tooltip
npm i @radix-ui/react-popover
# 每个组件独立 package,按需装
或者一次性装常用的:
npm i @radix-ui/themes # 带默认主题的全套
Dropdown Menu 例子
import * as DropdownMenu from '@radix-ui/react-dropdown-menu'
function UserMenu() {
return (
<DropdownMenu.Root>
<DropdownMenu.Trigger asChild>
<button>菜单 ↓</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content
className="bg-white shadow-lg rounded-md p-1 min-w-[180px]"
sideOffset={4}
>
<DropdownMenu.Item
className="px-3 py-2 rounded hover:bg-gray-100 outline-none cursor-pointer"
onSelect={() => navigate('/profile')}
>
个人主页
</DropdownMenu.Item>
<DropdownMenu.Item
className="px-3 py-2 rounded hover:bg-gray-100"
onSelect={() => navigate('/settings')}
>
设置
</DropdownMenu.Item>
<DropdownMenu.Separator className="h-px bg-gray-200 my-1" />
<DropdownMenu.Item
className="px-3 py-2 rounded hover:bg-red-50 text-red-600"
onSelect={logout}
>
退出登录
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
)
}
没写任何 keyboard handler / focus trap / ARIA,但 Radix 内部
全做好了:
- ↑↓ 切换项目
- Home / End 跳首末
- Enter 选中
- Escape 关闭
- 点外面关闭
- 输入字符跳到对应项
- aria-expanded / aria-haspopup / aria-orientation 等 ARIA 全配
- focus 进入 / 离开管理
- screen reader 报"opened menu, 3 items, item 1 of 3"
业务代码只关心"我有哪些菜单项 + 点击做什么"。
Dialog 例子(合规 modal)
import * as Dialog from '@radix-ui/react-dialog'
function DeleteConfirm({ onDelete }) {
return (
<Dialog.Root>
<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 p-6 rounded-lg max-w-md">
<Dialog.Title className="text-lg font-bold">确认删除</Dialog.Title>
<Dialog.Description className="text-sm text-gray-500 mt-1">
此操作不可撤销。
</Dialog.Description>
<div className="mt-4 flex gap-2 justify-end">
<Dialog.Close asChild>
<button>取消</button>
</Dialog.Close>
<button onClick={onDelete} className="btn-danger">删除</button>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
)
}
内置:
- focus trap(Tab 不出对话框)
- ESC 关闭
- 点 overlay 关闭
- 自动 focus 第一可聚焦元素
- 关闭后焦点回到 trigger
- 背景 inert / aria-hidden 屏蔽
- title / description 自动关联到对话框 aria-labelledby / aria-describedby
合规模态 5 行配置。
Tooltip / Popover
import * as Tooltip from '@radix-ui/react-tooltip'
<Tooltip.Provider delayDuration={200}>
<Tooltip.Root>
<Tooltip.Trigger asChild>
<button>?</button>
</Tooltip.Trigger>
<Tooltip.Portal>
<Tooltip.Content className="bg-black text-white p-2 rounded text-sm" sideOffset={4}>
这是一段提示
<Tooltip.Arrow className="fill-black" />
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
</Tooltip.Provider>
Tooltip 之类需要全局 Provider 控制 hover delay 一致。
Accordion
import * as Accordion from '@radix-ui/react-accordion'
<Accordion.Root type="single" collapsible className="w-[400px]">
<Accordion.Item value="item-1">
<Accordion.Header>
<Accordion.Trigger className="w-full text-left">
什么是 React Hooks?
</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>
Hooks 是 React 16.8 引入的特性...
</Accordion.Content>
</Accordion.Item>
<Accordion.Item value="item-2">
...
</Accordion.Item>
</Accordion.Root>
type='single' 同时只展开一个;type='multiple' 多个。
键盘 Tab / Space / Enter 展开收起,箭头切项。
Switch / Checkbox / RadioGroup
import * as Switch from '@radix-ui/react-switch'
<Switch.Root
className="w-11 h-6 bg-gray-300 rounded-full relative data-[state=checked]:bg-blue-500"
checked={isDark}
onCheckedChange={setIsDark}
>
<Switch.Thumb className="block w-5 h-5 bg-white rounded-full shadow translate-x-0.5 data-[state=checked]:translate-x-5 transition" />
</Switch.Root>
完整 keyboard support + ARIA + 适合 screen reader 的 label 关联。
asChild 模式
很多 Radix 组件支持 asChild:
<DropdownMenu.Trigger asChild>
<button>Click</button> {/* 把 trigger 的行为应用到这个 button */}
</DropdownMenu.Trigger>
// vs 默认(包一层 button)
<DropdownMenu.Trigger>Click</DropdownMenu.Trigger>
asChild 让你用自己的元素(甚至自定义 styled component)作为 trigger,
不强制额外 DOM。React Aria / Headless UI 也类似。
跟 shadcn/ui 关系
shadcn/ui = "Radix Primitives + Tailwind 样式 + 复制粘贴" 的预设。
直接:
npx shadcn-ui@latest add dropdown-menu dialog tooltip
代码进你的 components/ui/,底层就是 Radix。
省了"自己包样式" 这一步。
跟 Headless UI / React Aria 对比
| Radix UI | Headless UI | React Aria (Adobe) | |
|---|---|---|---|
| 风格 | 较细粒度 primitive | 简洁 | 极完整(也最重) |
| 组件数 | ~30 | 10+ | ~50 |
| Tailwind 友好 | ✅ | ✅(同公司) | 中性 |
| 学习曲线 | 中 | 低 | 中-高 |
| a11y 严谨度 | 高 | 高 | 极高 |
| bundle | 中(按需) | 小 | 中-大 |
shadcn 默认用 Radix;个人偏好用 Headless UI(Tailwind 同公司)也很
好。
实际效果
我们一个 React app 把所有 dialog / dropdown / popover / tooltip 改 Radix:
- 之前自己实现 + 半成品 → bug 多 + 不一致
- 改 Radix 后 a11y 自动达标(axe-core lint 0 violation)
- bundle 增加 ~30KB(5-6 个 primitive)
- 团队 review focus 从"键盘怎么操作" → 只看业务逻辑
几个建议
- 不要从 Radix 自己包 100 个 wrapper:用 shadcn 把 Radix + Tailwind
集成代码丢进自己 repo 直接改更灵活 - 状态管理交给 Radix:除非需要受控 (controlled mode),让 Radix
内部管openstate - portal 默认开:菜单 / dialog 进 portal 避免 overflow / z-index
父元素影响 - className 接受:所有 primitive 都接 className + style,
尽情用 Tailwind / CSS Modules
踩过的坑
-
DropdownMenu 不能 nested:嵌套菜单要用
DropdownMenu.Sub,
不是再嵌一个 Root。 -
Dialog 内嵌 Tooltip 不显示:z-index / portal 父子关系问题。
Tooltip 也 portal 出去。 -
asChild+ Link:React Router<Link>已经是<a>,
配asChild时不要重复包<a>。 -
server side rendering:Radix 用 useId / 假 portal,SSR 时
小心 hydration mismatch。包 dynamic import / suppress hydration warning。 -
bundle 一个 component 拉一堆 dep:每个 Radix component 拉
@radix-ui/react-primitive等 shared util。多组件用同样 base
tree-shake 后实际不重复,但单独看 size 比想象大。
登录后参与评论。