shadcn/ui 与 Radix:自定义组件时如何保持无障碍
上周有个同事问我:“这个按钮用键盘怎么点不了?”
我愣了一下。我们明明用的是 shadcn/ui,怎么会出这种问题?打开 DevTools一看,才发现他把 Tooltip.Trigger 外面包了个 <div> —— 为了加个自定义样式。问题就出在这儿。
说实话,我也踩过类似的坑。刚开始用 shadcn/ui 时,总觉得这些组件”能随便改”,毕竟代码都复制到项目里了嘛。改改样式、换个标签、加个 wrapper,看起来也没问题。直到某天 QA 测试时发现:键盘操作失效,屏幕阅读器读不出内容,整个交互流程断了。
这才意识到,shadcn/ui 的”自由”是有代价的。它给你源代码,但背后藏着 Radix 的无障碍魔法。一旦乱改,这层魔法就破。
这篇文章聊聊 shadcn/ui 和 Radix 的关系,重点讲自定义组件时怎么保持无障碍。读完你大概能搞清楚 asChild 怎么用、焦点管理怎么处理、ARIA 属性怎么继承——至少下次改组件时,知道哪些能动,哪些碰不得。
shadcn/ui 和 Radix:到底是什么关系?
先说个很多人没搞清楚的事:shadcn/ui 不是 npm 包。
你没法用 npm install @shadcn/ui 安装它。它本质上是个”代码分发平台”——给你组件的源代码,你复制到项目里,之后这些代码就完全属于你了。想改就改,想删就删,没人管。
那这些组件的无障碍功能从哪来?Radix。
Radix UI 是一套”无样式组件库”,也叫 Primitives。它不给你样式,只给你行为——比如 Dialog 打开时焦点怎么管理、Dropdown Menu 怎么处理键盘上下键、Tooltip 怎么在屏幕阅读器里隐藏。这些东西都符合 WAI-ARIA 规范,而且经过 NVDA、JAWS、VoiceOver 这些主流 screen reader 的实测。
shadcn/ui 就是在 Radix 上铺了一层 Tailwind CSS 样式。给你好看的外观,同时把 Radix 的无障碍行为藏在下面。你复制按钮代码,看起来只是几行 Tailwind 类名,但里面其实包着 Radix 的逻辑。
说得再直白点:
- Radix 负责”能用”:aria 属性、role、焦点管理、键盘导航
- shadcn/ui 负责”好看”:Tailwind 样式、设计一致性
所以当你改 shadcn/ui 组件时,要记住:你在改的是”表层”,但”底层”的行为逻辑来自 Radix。表层随便改,底层改错了就出事。
asChild 属性:魔法还是陷阱?
asChild 是 Radix 里一个很特别的属性。大多数 Radix 组件的每个”部分”都支持它。
什么意思呢?比如 Tooltip.Trigger 默认渲染成一个 <button> 元素。但你可能想把 Tooltip 加到一个链接上,这时候就用 asChild:
<Tooltip.Trigger asChild>
<a href="/help">帮助中心</a>
</Tooltip.Trigger>
设置 asChild={true} 后,Radix 不再渲染自己的 <button>,而是”克隆”你提供的子元素,把它的行为和属性传过去。这个链接就有了 Tooltip Trigger 的所有功能:鼠标悬停显示 Tooltip、键盘聚焦也能触发、正确的 aria 属性。
看起来很方便。
但陷阱也在这儿。
如果你换成一个不可聚焦的元素,整个无障碍就没了。
// ❌ 错误示范
<Tooltip.Trigger asChild>
<div className="my-custom-wrapper">点击我</div>
</Tooltip.Trigger>
div 不能被键盘聚焦(除非手动加 tabIndex={0}),也不响应 Enter/Space 键。屏幕阅读器不会把它当成按钮。用户用键盘操作时,根本”碰不到”这个 Tooltip。
Radix 官方文档明确说过:“If you were to switch it to a div, it would no longer be accessible.”
话说回来,大部分时候你不会直接换成 div。更常见的情况是用自己的 React 组件:
<Tooltip.Trigger asChild>
<MyButton>点击我</MyButton>
</Tooltip.Trigger>
这没问题,但有两个必须遵守的规则:
1. 你的组件必须 spread props
Radix 克隆子元素时会传一堆属性:事件处理器、aria 属性、ref。如果你的组件不接收这些属性,功能就断了。
// ❌ 错误:不接受 props
const MyButton = () => <button className="btn">...</button>
// ✅ 正确:spread 所有 props
const MyButton = (props) => <button className="btn" {...props}>...</button>
2. 你的组件必须 forward ref
Radix 有时需要直接访问 DOM 元素(比如测量尺寸、管理焦点)。不给 ref 就会报错。
// ❌ 错误:不接受 ref
const MyButton = (props) => <button {...props}>...</button>
// ✅ 正确:forward ref
const MyButton = React.forwardRef((props, ref) => (
<button {...props} ref={ref}>...</button>
))
说实话,这两条规则不光是 Radix 需要,写任何”叶子组件”都该这么做。接受所有 props 和 ref 是基本素养。
还有一个好玩的用法:多个 Radix 组件可以套在一起。
<Tooltip.Trigger asChild>
<Dialog.Trigger asChild>
<MyButton>打开弹窗</MyButton>
</Dialog.Trigger>
</Tooltip.Trigger>
一个按钮,同时是 Tooltip Trigger 和 Dialog Trigger。两种行为叠加,都没问题。
焦点管理和键盘导航
焦点管理是无障碍里最容易被忽视的部分。
很多人只想着”样式好看”,但忘了用户可能不用鼠标。键盘用户、屏幕阅读器用户,他们的操作完全依赖焦点位置。
Radix 在这方面做了很多自动处理。举个例子:
AlertDialog 打开时,焦点自动移到 Cancel 按钮。
这是个精心设计的细节。AlertDialog 通常用于确认危险操作(删除、退出)。用户打开弹窗后,最可能的动作是”取消”而非”确认”。焦点直接放在 Cancel 上,用户只要按一下 Enter 就能关闭弹窗,避免误操作。
如果焦点停在 Confirm 按钮上呢?用户不小心按 Enter,直接执行了删除。灾难。
这个行为是 Radix 根据 WAI-ARIA authoring practices 实现的。你不用自己写代码。
但问题来了:如果你自定义了 AlertDialog 内容,焦点可能跑错地方。
比如你在弹窗里加了一个输入框:
<AlertDialog.Content>
<AlertDialog.Title>确认删除?</AlertDialog.Title>
<AlertDialog.Description>请输入"DELETE"确认</AlertDialog.Description>
<input placeholder="输入 DELETE" /> {/* 你加的 */}
<AlertDialog.Cancel>取消</AlertDialog.Cancel>
<AlertDialog.Action>确认</AlertDialog.Action>
</AlertDialog.Content>
现在打开弹窗,焦点会去哪里?
Radix 默认会找第一个可聚焦元素。你的 input 在 Cancel 前面,焦点就跑到 input 里。用户得按几次 Tab 才能点到 Cancel。这打断了预期的流程。
解决方案:用 autoFocus 指定焦点目标,或者调整元素顺序。
<AlertDialog.Content>
<AlertDialog.Title>确认删除?</AlertDialog.Title>
<AlertDialog.Description>请输入"DELETE"确认</AlertDialog.Description>
<AlertDialog.Cancel autoFocus>取消</AlertDialog.Cancel> {/* 强制焦点 */}
<input placeholder="输入 DELETE" />
<AlertDialog.Action>确认</AlertDialog.Action>
</AlertDialog.Content>
把 Cancel 移到前面,或者给它加 autoFocus。这样焦点就不会跑错。
键盘导航也有类似问题。
Tabs 组件:用户用左右箭头切换 tab,这是 WAI-ARIA 标准行为。如果你给 tab 加了自定义样式,不小心把 role="tab" 覆盖了,键盘导航就失效。
Dropdown Menu:上下箭头选择菜单项,Enter 确认,Esc 关闭。这些都是 Radix 内部处理的。但如果你给菜单项加了 onClick 而没用 onSelect,可能会破坏键盘行为。
测试方法简单粗暴:把鼠标扔一边,只用键盘操作整个组件流程。
- Tab 能进入组件吗?
- 箭头键能切换选项吗?
- Enter 能触发操作吗?
- Esc 能关闭弹窗吗?
如果任何一个环节卡住,说明无障碍有问题。
ARIA 属性的自动继承
ARIA 属性这块,Radix 帮你省了不少心。
它自动给组件加上正确的 role、aria-* 属性。比如:
- Dialog 会加上
role="dialog"和aria-modal="true" - Tabs.Tab 会加上
role="tab"和aria-selected - Switch 会加上
role="switch"和aria-checked
这些你都不用管。Radix 内部处理好了。
但有一件事你必须做:给控件提供 accessible name。
屏幕阅读器用户要知道这个按钮是什么、这个弹窗叫什么、这个输入框填什么。没有名称,他们只能猜。
Radix 提供了 Label primitive 来帮你:
<Label.Root htmlFor="email-input">邮箱地址</Label.Root>
<Input id="email-input" />
这个 Label.Root 会自动关联到 input,屏幕阅读器朗读时会先说”邮箱地址”,然后读出输入框的值。
对于自定义控件(不是原生 input),你得手动提供名称。
<Switch aria-label="启用夜间模式" />
<Tabs.Tab aria-label="产品详情" />
或者用 aria-labelledby 关联一个可见的文本:
<div id="mode-label">夜间模式</div>
<Switch aria-labelledby="mode-label" />
验证方法:打开屏幕阅读器,操作一遍。
Mac 上有 VoiceOver(Cmd+F5 启动),Windows 上有 NVDA(免费下载)。听听它们怎么读你的组件。如果读出来是”按钮”而不是”提交订单按钮”,说明缺少 accessible name。
还有一点:颜色对比度。
Radix 不管样式,所以颜色对比度是你的责任。WCAG 要求文本和背景的对比度至少 4.5:1(普通文本)或 3:1(大文本)。shadcn/ui 的默认配色通常达标,但你自己改颜色时得注意。
有个工具叫 WebAIM Contrast Checker,输入前景色和背景色就能算出对比度。
实战检查清单
每次自定义 shadcn/ui 组件后,用这份清单验证一下:
asChild 检查
asChild的子元素是可聚焦元素吗?(button/a/input,不是 div)- 自定义组件 spread 了所有 props?
- 自定义组件 forward 了 ref?
焦点管理检查
- 弹窗打开时,焦点去了正确的位置?
- 弹窗关闭后,焦点回到了触发元素?
- 有嵌套的可聚焦元素时,焦点顺序合理?
键盘导航检查
- Tab 能进入组件?
- 箭头键能切换选项(Tabs、Dropdown)?
- Enter 能触发操作?
- Esc 能关闭弹窗?
- Space 能切换状态(Switch、Checkbox)?
ARIA 检查
- 每个控件都有 accessible name?
- 屏幕阅读器能正确朗读角色和状态?
- 动态状态变化有正确的 aria-live 区域?
视觉检查
- 焦点指示器清晰可见?
- 颜色对比度达标(4.5:1 或 3:1)?
- 不只用颜色传达信息(有图标或文字辅助)?
测试工具
- 键盘测试:拔掉鼠标,只用键盘操作整个流程
- 屏幕阅读器:VoiceOver(Mac)或 NVDA(Windows)
- 自动化:axe DevTools 浏览器插件
结论
shadcn/ui 给了你代码的自由,但这自由有边界。
边界就是 Radix 的无障碍行为。你可以改样式、改布局、改类名,但不能破坏底层的行为逻辑。一旦把 button 换成 div,或者忘了 spread props,键盘用户就遭殃了。
记住几个要点:
- asChild 时:子元素必须可聚焦,自定义组件要 spread props + forward ref
- 焦点管理:自定义弹窗内容时,检查焦点去了哪里
- ARIA 属性:Radix 自动加 role,但你得提供 label
下次改组件时,先用键盘操作一遍。发现问题就修复,别等 QA 来找你。
说到底,无障碍不是”额外功能”,是基础要求。shadcn/ui 和 Radix 已经帮你做了最难的部分,剩下的就是别把它们的好意给毁了。
常见问题
shadcn/ui 和 Radix UI 是什么关系?
asChild 属性怎么用才不会破坏无障碍?
自定义弹窗内容时,焦点管理要注意什么?
怎么测试组件的无障碍是否正常?
Radix 自动加了 aria 属性,我还需要做什么?
10 分钟阅读 · 发布于: 2026年3月30日 · 修改于: 2026年3月30日
相关文章
Nginx 反向代理完全指南:upstream、缓冲与超时
Nginx 反向代理完全指南:upstream、缓冲与超时
Tailwind 性能优化:JIT、content 配置与生产体积控制
Tailwind 性能优化:JIT、content 配置与生产体积控制
Dialog、Sheet、Popover:弹层类组件的可达性与焦点管理

评论
使用 GitHub 账号登录后即可评论