React 组件的可访问性实战
语义 HTML 不够——自定义 React 组件(模态框/Dropdown/Tab/Toast)需要手动处理 a11y:焦点管理、键盘导航、ARIA、屏幕阅读器通知。本文讲清这些常用组件的 a11y 模式,让你写的自定义交互组件也能被所有人用。
本文要点
- 主题聚焦:React、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
很多前端开发者对可访问性(a11y)有一种错觉:"我用的是语义化 HTML,<button>、<nav>、<main> 都对,那应该就没问题了吧?"
错。一旦你开始写自定义交互组件——模态框、下拉菜单、标签页、Toast——原生 HTML 就没有对应语义了。一个用 <div onClick> 堆出来的"弹窗",在屏幕阅读器眼里就是一堆没名字的方块;键盘用户连"打开后焦点去哪"都不知道。WAI-ARIA Authoring Practices(APG)就是为这些"原生 HTML 表达不了"的场景准备的设计模式库,但很多团队压根没读过。
这篇讲清四类高频自定义组件的 a11y 模式,以及为什么我强烈建议你别自己手写。
一、为什么语义 HTML 还不够
原生 HTML 元素自带三层 a11y:
- 语义角色:
<button>天然是role="button",屏幕阅读器会念"按钮"。 - 键盘行为: 可以 Tab 聚焦、回车/空格触发、
disabled自动跳过。 - 焦点管理: 浏览器自动画焦点环、维护 tab order。
但 React 里大量组件没有原生等价物:Dialog、Menu、Tabs、Combobox、Toast、Tooltip……你能找到的"最像"的原生元素(dialog 元素除外)往往只是 <div>。一旦用 <div> 承载交互,上面三层全部丢失,你必须手动补:
| 缺失 | 怎么补 |
|---|---|
| 语义角色 | role="dialog" 等 ARIA 角色 |
| 可聚焦 | tabIndex={0},键盘可触发 |
| 键盘行为 | onKeyDown 处理 Enter/Esc/Arrow |
| 焦点管理 | 打开进、关闭回、陷阱内 |
| 屏幕阅读器通知 | aria-live、aria-modal、aria-expanded |
下面四节直接看代码。
二、模态框(Dialog):焦点陷阱与焦点回退
模态框的 a11y 有四条硬要求:
role="dialog"+aria-modal="true",告诉屏幕阅读器这是模态。- 打开时焦点进入模态(通常进到第一个可聚焦元素或标题)。
- 焦点陷阱:Tab 在模态内循环,跑不出去(否则用户 Tab 到背景页就迷路了)。
- 关闭后焦点回到触发器(焦点回退),Escape 键关闭。
import { useEffect, useRef } from "react";
interface ModalProps {
open: boolean;
onClose: () => void;
titleId: string;
children: React.ReactNode;
}
function Modal({ open, onClose, titleId, children }: ModalProps) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => {
if (!open) return;
// 记住打开前的焦点,关闭后回到这里
previousFocus.current = document.activeElement as HTMLElement;
const dialog = dialogRef.current;
if (!dialog) return;
const getFocusable = () =>
Array.from(
dialog.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
)
).filter((el) => el.offsetParent !== null); // 排除隐藏元素
// 焦点进入模态
const focusable = getFocusable();
focusable[0]?.focus();
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key !== "Tab") return;
// 焦点陷阱:Tab 在模态内循环
const items = getFocusable();
if (items.length === 0) {
e.preventDefault();
dialog.focus();
return;
}
const first = items[0];
const last = items[items.length - 1];
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
return () => {
document.removeEventListener("keydown", onKeyDown);
// 焦点回退到触发器
previousFocus.current?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
>
{children}
</div>
);
}
注意 useEffect 清理函数里的 previousFocus.current?.focus()——这是模态关闭后焦点不丢失的关键。漏掉这一行,键盘用户关掉弹窗后焦点会跑到页面顶部,等于把他扔回起点。
三、Dropdown/Menu:aria-expanded 与方向键
Dropdown 触发器和菜单之间必须用 ARIA 关联起来:
function Dropdown() {
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuId = useId();
return (
<>
<button
ref={triggerRef}
aria-haspopup="true"
aria-expanded={open}
aria-controls={menuId}
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setOpen(true);
}
}}
>
菜单
</button>
{open && (
<ul role="menu" id={menuId}>
<li
role="menuitem"
tabIndex={-1}
onKeyDown={(e) => {
if (e.key === "Escape") {
setOpen(false);
triggerRef.current?.focus(); // 关闭后回触发器
}
}}
>
编辑
</li>
{/* Arrow Up/Down 在 menuitem 间移动、Home/End 跳首尾 */}
</ul>
)}
</>
);
}
要点:
aria-expanded是屏幕阅读器区分"按钮"和"已展开的按钮"的唯一信号。漏了它,盲人用户根本不知道菜单开了没。aria-haspopup="true"(或"menu")告诉辅助技术这是菜单类弹出。- 键盘模型:Enter/Space 打开,Arrow 上下移动,Escape 关闭并回到触发器,Tab 通常不参与菜单内导航(APG 推荐)。
- 滚动/点击外部关闭要监听
click和scroll事件,记得在 effect 里 cleanup。
老实说,Dropdown 的完整 APG 模式(包括 roving tabindex、type-ahead 字符跳转)极其繁琐。这种组件我从不手写——见第七节。
四、Tab 标签页:ARIA 完整模式
Tab 是 ARIA 用得最密集的组件之一,三件套 role="tablist" / tab / tabpanel 必须配对,且 aria-selected、aria-controls、aria-labelledby、tabIndex 的 roving 模式一个都不能少。
import { useId, useRef, useState } from "react";
interface TabItem {
id: string;
label: string;
content: React.ReactNode;
}
function Tabs({ tabs }: { tabs: TabItem[] }) {
const [active, setActive] = useState(0);
const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
const onKeyDown = (e: React.KeyboardEvent, index: number) => {
const count = tabs.length;
const focusTab = (next: number) => {
setActive(next);
requestAnimationFrame(() => tabRefs.current[next]?.focus());
};
switch (e.key) {
case "ArrowRight":
e.preventDefault();
focusTab((index + 1) % count);
break;
case "ArrowLeft":
e.preventDefault();
focusTab((index - 1 + count) % count);
break;
case "Home":
e.preventDefault();
focusTab(0);
break;
case "End":
e.preventDefault();
focusTab(count - 1);
break;
}
};
return (
<div>
<div role="tablist" aria-label="设置">
{tabs.map((tab, i) => (
<button
key={tab.id}
ref={(el) => {
tabRefs.current[i] = el;
}}
role="tab"
id={`tab-${tab.id}`}
aria-selected={active === i}
aria-controls={`panel-${tab.id}`}
tabIndex={active === i ? 0 : -1} // roving tabindex
onClick={() => setActive(i)}
onKeyDown={(e) => onKeyDown(e, i)}
>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, i) => (
<div
key={tab.id}
role="tabpanel"
id={`panel-${tab.id}`}
aria-labelledby={`tab-${tab.id}`}
tabIndex={0}
hidden={active !== i}
>
{tab.content}
</div>
))}
</div>
);
}
几个容易翻车的点:
tabIndex用 roving 模式:只有当前 tab 是0,其它是-1。Tab 键跳到 tablist 后只停一次,再用方向键切换。如果所有 tab 都是0,用户得按 N 次 Tab 才能穿过 tablist,体验灾难。aria-selected而不是aria-current:这俩不通用,Tab 必须用aria-selected。tabpanel要hidden:非激活的 panel 必须用hidden属性隐藏,而不是仅靠 CSS。这样屏幕阅读器不会读到隐藏内容。- 方向键切换必须同步移动焦点:光
setActive不够,用户按方向键后期望焦点跟着走(requestAnimationFrame等渲染完再 focus)。
五、Toast/通知:aria-live 让屏幕阅读器自动朗读
Toast 的难点不是样式,而是异步出现的 DOM 怎么通知屏幕阅读器。直接往页面塞一个 <div>,盲人用户什么都听不到——因为他们的虚拟缓冲区不知道这里有新内容。
解法是 aria-live 区域:
// 断言型错误:立刻打断,适合严重错误
<div role="alert" aria-live="assertive">
保存失败,请重试
</div>
// 礼貌型通知:等屏幕阅读器空闲再念,适合 Toast
<div role="status" aria-live="polite">
已保存到草稿
</div>
实战建议:
- 优先用
role="status"(等价aria-live="polite"+aria-atomic="true"),只在真正的错误告警用role="alert"。assertive会强行打断用户当前操作,滥用很烦。 - 容器一开始就要在 DOM 里(可以是空),不要等到要发通知时才挂载——某些屏幕阅读器对后插入的 live region 不敏感。
- 动态文字要稳定:频繁变更内容会让 NVDA 反复朗读,加一点 debounce。
六、焦点管理的三条铁律
把上面的零碎点抽出来,自定义组件的焦点管理就三条:
- 焦点陷阱(focus trap):模态、抽屉这类"霸占式"容器,Tab 必须在内部循环,出不去。否则背景页的可聚焦元素会"漏"进来。
- 焦点回退(focus restore):任何"打开后抢走焦点"的组件(模态、菜单、Command Palette),关闭时都必须把焦点还给触发它的元素。否则键盘用户直接被传送到页面顶部。
- 可见的焦点环(visible focus):
outline: none是 a11y 重灾区。如果你确实嫌默认 outline 丑,必须自己写一套:focus-visible样式替代,绝对不能裸奔。Tailwind 用户记得别在全局 reset 里把 outline 干掉。
七、别手写:用 Radix UI 或 react-aria
说了这么多,我的真实建议是:生产环境别自己手写这些组件。
- Radix UI:无样式(可选 Tailwind 主题)、uncontrolled 友好、API 设计非常 React 风格,焦点管理和 ARIA 全部内置。
@radix-ui/react-dialog、react-dropdown-menu、react-tabs直接拿来用。 - react-aria(Adobe):更底层、hook 形式,提供完整 a11y 行为但完全不管样式,适合要深度自定义的设计系统团队。
手写一个完全符合 APG 的 Dropdown 可能要 300 行代码,覆盖键盘导航、roving tabindex、type-ahead、焦点回退、外部点击关闭、Esc 关闭、SSR 安全……漏一个就是 bug。Radix 把这些全做了,而且被几十万项目验证过。工程师的价值不在于重新发明模态框,而在于把精力花在业务侧真正复杂的交互上。
自己写唯一的合理场景是:学习 a11y、或组件库团队没有第三方依赖预算。即便如此,也应该拿 APG 的官方示例逐条对齐测试。
八、测试:键盘走一遍,屏幕阅读器听一遍
代码写完不算完,必须测。两道关:
第一关:键盘测试。 把鼠标拔了,只用键盘:
- Tab/Shift+Tab 能否到达所有可交互元素?
- 焦点顺序是否符合视觉顺序?
- 模态打开后焦点是否被困在模态内?关闭后是否回到触发器?
- 所有操作能否只用键盘完成?
第二关:屏幕阅读器测试。 至少跑一个:
- macOS 内置 VoiceOver(Cmd+F5 开启,免费且足够)。
- Windows 用 NVDA(免费)。
- 监听它实际朗读的内容:角色、名称、状态、值是否都对?有没有听到"button"、听到展开状态、听到 Toast 内容?
很多团队上线前只过视觉走查,结果 a11y 一塌糊涂。这两关各五分钟,几乎能抓出 80% 的 a11y 问题。
小结
- 语义 HTML 只覆盖原生组件;模态、菜单、Tab、Toast 等自定义交互组件必须手动补 ARIA 和键盘行为。
- 模态的 a11y 四件套:
role="dialog"+aria-modal+ 焦点陷阱 + 焦点回退(外加 Esc 关闭)。 - Tab 必须用 roving tabindex,且 tab/tabpanel 三件套配齐
aria-selected/aria-controls/aria-labelledby,非激活 panel 用hidden。 - Toast 用
role="status"(polite)或role="alert"(assertive),容器常驻 DOM。 - 焦点管理三条铁律:陷阱、回退、可见焦点环——别
outline: none裸奔。 - 生产环境别手写,用 Radix UI 或 react-aria,把精力留给业务侧复杂交互。
可访问性不是给少数人的"额外福利",它是工程质量的一部分——一个键盘用户都过不去的组件,本质上就是没写完。
Related