前端无障碍模式 是做什么的?
针对 React 和 Next.js 的实用无障碍模式。涵盖了代码审查中最常被指出的问题:表单标签缺失、ARIA 使用错误、非语义化交互元素以及损坏的键盘导航。
何时激活
- 构建或审查表单组件(
<input>、<select>、<textarea>) - 创建交互元素(模态框、下拉菜单、工具提示、选项卡)
- 使用带有
onClick的<div>或<span> - 为任何元素添加
aria-*属性 - 实现键盘导航或焦点管理
- 收到来自代码审查工具(CodeRabbit、ESLint a11y)的无障碍反馈
- 构建必须支持屏幕阅读器的组件
表单无障碍
htmlFor / id 配对缺失以及错误消息未关联是代码审查中最常见的问题。
标签关联
// 错误:标签与输入框没有关联——屏幕阅读器无法将它们联系起来
<label>Email</label>
<input type="email" />
// 正确:htmlFor 与 input id 匹配
<label htmlFor="email">Email</label>
<input id="email" type="email" />
必填字段
// 错误:仅视觉上的星号无法传达给屏幕阅读器
<label htmlFor="email">Email *</label>
<input id="email" type="email" />
// 正确:required 启用原生浏览器验证;aria-required 向屏幕阅读器发出信号
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
错误消息
// 错误:错误文本在视觉上存在,但未链接到输入框
<input id="email" type="email" />
<span className="error">Invalid email address</span>
// 正确:aria-describedby 将输入框与其错误消息关联
// aria-invalid 向屏幕阅读器指示无效状态
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!error}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
完整无障碍表单
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
if (!email) newErrors.email = 'Email is required';
if (!password) newErrors.password = 'Password is required';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
Password <span aria-hidden="true">*</span>
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">Log in</button>
</form>
);
}
语义化 HTML
使用与意图匹配的元素。屏幕阅读器和键盘用户依赖原生语义。
// 错误:div 没有角色、没有键盘支持、没有可访问名称
<div onClick={handleClick}>Submit</div>
// 正确:button 可聚焦,在 Enter/Space 上激活,并宣布为“按钮”
<button type="button" onClick={handleClick}>Submit</button>
// 错误:非语义化导航
<div onClick={() => navigate('/home')}>Home</div>
// 正确:锚点支持右键单击、中键单击和键盘导航
<a href="/home">Home</a>
// 错误:标题层级跳过(h1 到 h4)
<h1>Dashboard</h1>
<h4>Recent Activity</h4>
// 正确:连续的标题层级
<h1>Dashboard</h1>
<h2>Recent Activity</h2>
ARIA 属性
仅在原生 HTML 语义不足时使用 ARIA。错误的 ARIA 比没有 ARIA 更糟糕。
aria-label 与 aria-labelledby
// aria-label:内联字符串标签——在没有可见标签文本时使用
<button aria-label="Close modal">
<XIcon />
</button>
// aria-labelledby:引用另一个元素的文本——当存在可见标签时使用
<section aria-labelledby="section-title">
<h2 id="section-title">Recent Orders</h2>
{/* 内容 */}
</section>
aria-describedby
// 提供标签之外的补充描述
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> Delete account
</button>
<p id="delete-warning">This action cannot be undone.</p>
动态内容的 aria-live
// 使用 aria-live 来宣布无需重新加载页面即可更新的内容
// polite:等待用户完成当前操作后再宣布
// assertive:立即中断——仅在紧急错误时使用
export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
return (
<div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
{message}
</div>
);
}
aria-expanded 和 aria-controls
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}
键盘导航
每个交互元素都必须能够单独通过键盘到达和操作。
自定义下拉菜单
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const listId = useId();
if (!options.length) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) onSelect(options[activeIndex]);
setIsOpen(prev => !prev);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listId}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(prev => !prev)}
>
<span>{options[activeIndex]}</span>
{isOpen && (
<ul id={listId} role="listbox">
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
焦点管理
当 UI 状态发生变化时,焦点必须逻辑地移动——特别是对于模态框和路由切换。
模态框焦点恢复
此示例涵盖了初始焦点和恢复。对于完整的焦点陷阱(Tab/Shift+Tab 在模态框内循环),请使用像
focus-trap-react这样的库,它处理了动态内容和嵌套 portal 等边缘情况。
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 保存当前聚焦的元素并将焦点移入模态框
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
// 将焦点恢复到打开模态框的元素
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>Close</button>
</div>
);
}
图片和图标
// 错误:装饰性图标被宣布为未标记的图像
<img src="/icon.svg" />
// 正确:对屏幕阅读器隐藏装饰性图像
<img src="/decoration.png" alt="" aria-hidden="true" />
// 正确:具有描述性替代文本的有意义图像
<img src="/chart.png" alt="Monthly revenue increased 23% from January to March" />
// 正确:带有可访问标签的图标按钮
<button aria-label="Delete item">
<TrashIcon aria-hidden="true" />
</button>
减少动画
尊重那些在操作系统设置中请求减少动画的用户。
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// 使用示例
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<div
style={{
transition: reduceMotion ? 'none' : 'transform 300ms ease'
}}
>
{children}
</div>
);
}
反模式
// 错误:在非交互元素上使用 onClick,无键盘支持
<div onClick={handleClick}>Click me</div>
// 错误:在没有角色的 div 上使用 aria-label
<div aria-label="Navigation">...</div>
// 错误:使用 placeholder 替代标签
<input placeholder="Enter your email" />
// 错误:正数 tabIndex 导致不可预测的 Tab 顺序
<button tabIndex={3}>Submit</button>
// 错误:在可聚焦元素上使用 aria-hidden——键盘用户会被困住
<button aria-hidden="true">Open</button>
// 错误:在 div 上使用 role="button" 但没有键盘处理程序
<div role="button" onClick={handleClick}>Submit</div>
// 缺少:tabIndex={0},以及用于 Enter/Space 的 onKeyDown
检查清单
在提交任何交互式组件进行审查之前:
- 每个
<input>、<select>和<textarea>都通过htmlFor/id关联了<label> - 错误消息通过
aria-describedby链接并标记为role="alert" - 没有在
<div>或<span>上使用onClick而不带role、tabIndex和onKeyDown - 纯图标按钮具有
aria-label - 装饰性图像使用
alt=""和aria-hidden="true" - 模态框在关闭时恢复焦点(如需完整的 Tab/Shift+Tab 焦点循环,请使用像
focus-trap-react这样的库) - 动态内容更新使用
aria-live - 对于动画,尊重
prefers-reduced-motion
相关技能
frontend-patterns— 通用 React 组件和状态模式design-system— 设计令牌和组件一致性motion-foundations和motion-patterns:考虑无障碍的动画模式

