| name | frontend-a11y |
| description | React 和 Next.js 的无障碍模式——语义化 HTML、ARIA 属性、 表单标签、键盘导航、焦点管理和屏幕阅读器支持。 在构建任何交互式 UI 组件或表单时使用。
|
| origin | community |
前端无障碍模式
React 和 Next.js 的实用无障碍模式。涵盖代码审查中最常被标记的问题:缺失的表单标签、不正确的 ARIA 使用、非语义的交互元素和损坏的键盘导航。
何时激活
- 构建或审查表单组件(
<input>、<select>、<textarea>)
- 创建交互式元素(模态框、下拉菜单、工具提示、选项卡)
- 在
<div> 或 <span> 上使用 onClick
- 为任何元素添加
aria-* 属性
- 实现键盘导航或焦点管理
- 收到来自代码审查工具(CodeRabbit、ESLint a11y)的无障碍反馈
- 构建必须支持屏幕阅读器的组件
表单无障碍
缺失的 htmlFor / id 配对和断开的错误消息是代码审查中最常被标记的问题。
标签连接
<label>邮箱</label>
<input type="email" />
<label htmlFor="email">邮箱</label>
<input id="email" type="email" />
必填字段
<label htmlFor="email">邮箱 *</label>
<input id="email" type="email" />
<label htmlFor="email">
邮箱 <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
错误消息
<input id="email" type="email" />
<span className="error">邮箱地址无效</span>
<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 = '邮箱是必填的';
if (!password) newErrors.password = '密码是必填的';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
< = >
邮箱 *
setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
{errors.email}
)}
密码 *
setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
{errors.password}
)}
登录
);
}
语义化 HTML
使用与意图匹配的元素。屏幕阅读器和键盘用户依赖原生语义。
<div onClick={handleClick}>提交</div>
<button type="button" onClick={handleClick}>提交</button>
<div onClick={() => navigate('/home')}>首页</div>
<a href="/home">首页</a>
<h1>仪表板</h1>
<h4>最近活动</h4>
<h1>仪表板</h1>
<h2>最近活动</h2>
ARIA 属性
仅当原生 HTML 语义不足时使用 ARIA。错误的 ARIA 比没有 ARIA 更糟糕。
aria-label vs aria-labelledby
<button aria-label="关闭模态框">
<XIcon />
</button>
<section aria-labelledby="section-title">
<h2 id="section-title">最近订单</h2>
{/* 内容 */}
</section>
aria-describedby
<button
aria-describedby="delete-warning"
onClick={handleDelete}
>
删除账户
</button>
<p id="delete-warning">此操作无法撤销。</p>
aria-live 用于动态内容
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':
:
e.();
(isOpen) (options[activeIndex]);
( !prev);
;
:
();
;
}
};
(
);
}
焦点管理
当 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=>{title}
{children}
关闭
);
}
图像和图标
<img src="/icon.svg" />
<img src="/decoration.png" alt="" aria-hidden="true" />
<img src="/chart.png" alt="月收入从一月到三月增长了 23%" />
<button aria-label="删除项目">
<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={handleClick}>点击我</div>
<div aria-label="导航">...</div>
<input placeholder="输入您的邮箱" />
<button tabIndex={3}>提交</button>
<button aria-hidden="true">打开</button>
<div role="button" onClick={handleClick}>提交</div>
检查清单
在提交任何交互式组件进行审查之前:
相关技能
frontend-patterns — 通用 React 组件和状态模式
design-system — 设计令牌和组件一致性
motion-ui — 带无障碍考虑的动画模式