介绍
# Web Accessibility (WCAG 2.1 AA)
**状态**:生产环境就绪 ✅ **最后更新**:2026-01-14 **依赖**:无(框架无关) **标准**:WCAG 2.1 级别 AA
---
## 快速入门(5 分钟)
### 1. 语义化 HTML 基础
选择正确的元素——不要什么都用 `div`:
```html <!-- ❌ WRONG - divs with onClick --> <div onclick="submit()">Submit</div> <div onclick="navigate()">Next page</div>
<!-- ✅ CORRECT - semantic elements --> <button type="submit">Submit</button> <a href="/next">Next page</a> ```
**为什么这很重要:** - 语义化元素内置了键盘支持 - 屏幕阅读器会自动宣布其角色 - 浏览器提供默认的无障碍行为
### 2. 焦点管理
确保交互元素可通过键盘访问:
```css /* ❌ WRONG - removes focus outline */ button:focus { outline: none; }
/* ✅ CORRECT - custom accessible outline */ button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } ```
**关键点:** - 切勿在没有替代方案的情况下移除焦点轮廓 - 使用 `:focus-visible` 仅在键盘聚焦时显示 - 确保焦点指示器的对比度至少为 3:1
### 3. 文本替代
每个非文本元素都需要文本替代:
```html <!-- ❌ WRONG - no alt text --> <img src="logo.png"> <button><svg>...</svg></button>
<!-- ✅ CORRECT - proper alternatives --> <img src="logo.png" alt="Company Name"> <button aria-label="Close dialog"><svg>...</svg></button> ```
---
## 5 步无障碍流程
### 步骤 1:选择语义化 HTML
**元素选择决策树:**
``` Need clickable element? ├─ Navigates to another page? → <a href="..."> ├─ Submits form? → <button type="submit"> ├─ Opens dialog? → <button aria-haspopup="dialog"> └─ Other action? → <button type="button">
Grouping content? ├─ Self-contained article? → <article> ├─ Thematic section? → <section> ├─ Navigation links? → <nav> └─ Supplementary info? → <aside>
Form element? ├─ Text input? → <input type="text"> ├─ Multiple choice? → <select> or <input type="radio"> ├─ Toggle? → <input type="checkbox"> or <button aria-pressed> └─ Long text? → <textarea> ```
**完整指南请参阅 `references/semantic-html.md`。**
### 步骤 2:需要时添加 ARIA
**黄金法则:仅当 HTML 无法表达该模式时才使用 ARIA。**
```html <!-- ❌ WRONG - unnecessary ARIA --> <button role="button">Click me</button> <!-- Button already has role -->
<!-- ✅ CORRECT - ARIA fills semantic gap --> <div role="dialog" aria-labelledby="title" aria-modal="true"> <h2 id="title">Confirm action</h2> <!-- No HTML dialog yet, so role needed --> </div>
<!-- ✅ BETTER - Use native HTML when available --> <dialog aria-labelledby="title"> <h2 id="title">Confirm action</h2> </dialog> ```
**常见的 ARIA 模式:** - `aria-label` - 当可见标签不存在时使用 - `aria-labelledby` - 引用现有文本作为标签 - `aria-describedby` - 额外描述 - `aria-live` - 宣布动态更新 - `aria-expanded` - 可折叠/展开状态
**完整模式请参阅 `references/aria-patterns.md`。**
### 步骤 3:实现键盘导航
**所有交互元素都必须可通过键盘访问:**
```typescript // Tab order management function Dialog({ onClose }) { const dialogRef = useRef<HTMLDivElement>(null); const previousFocus = useRef<HTMLElement | null>(null);
useEffect(() => { // Save previous focus previousFocus.current = document.activeElement as HTMLElement;
// Focus first element in dialog const firstFocusable = dialogRef.current?.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'); (firstFocusable as HTMLElement)?.focus();
// Trap focus within dialog const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); if (e.key === 'Tab') { // Focus trap logic here } };
document.addEventListener('keydown', handleKeyDown); return () => { document.removeEventListener('keydown', handleKeyDown); // Restore focus on close previousFocus.current?.focus(); }; }, [onClose]);
return <div ref={dialogRef} role="dialog">...</div>; } ```
**基本的键盘模式:** - Tab/Shift+Tab:在可聚焦元素之间导航 - Enter/Space:激活按钮/链接 - 方向键:在组件内部导航(选项卡、菜单) - Escape:关闭对话框/菜单 - Home/End:跳转到第一个/最后一个项目
**完整模式请参阅 `references/focus-management.md`。**
### 步骤 4:确保颜色对比度
**WCAG AA 要求:** - 普通文本(小于 18pt):4.5:1 对比度 - 大文本(18pt+ 或 14pt+ 加粗):3:1 对比度 - UI 组件(按钮、边框):3:1 对比度
```css /* ❌ WRONG - insufficient contrast */ :root { --background: #ffffff; --text: #999999; /* 2.8:1 - fails WCAG AA */ }
/* ✅ CORRECT - sufficient contrast */ :root { --background: #ffffff; --text: #595959; /* 4.6:1 - passes WCAG AA */ } ```
**测试工具:** - 浏览器 DevTools(Chrome/Firefox 有内置检查器) - 对比度检查器扩展 - axe DevTools 扩展
**完整指南请参阅 `references/color-contrast.md`。**
### 步骤 5:使表单无障碍
**每个表单输入都需要可见的标签:**
```html <!-- ❌ WRONG - placeholder is not a label --> <input type="email" placeholder="Email address">
<!-- ✅ CORRECT - proper label --> <label for="email">Email address</label> <input type="email" id="email" name="email" required aria-required="true"> ```
**错误处理:**
```html <label for="email">Email address</label> <input type="email" id="email" name="email" aria-invalid="true" aria-describedby="email-error" > <span id="email-error" role="alert"> Please enter a valid email address </span> ```
**动态错误的 Live regions:**
```html <div role="alert" aria-live="assertive" aria-atomic="true"> Form submission failed. Please fix the errors above. </div> ```
**完整模式请参阅 `references/forms-validation.md`。**
---
## 关键规则
### 始终执行
✅ 优先使用语义化 HTML 元素(button, a, nav, article 等) ✅ 为所有非文本内容提供文本替代 ✅ 确保普通文本对比度为 4.5:1,大文本/UI 为 3:1 ✅ 使所有功能均可通过键盘访问 ✅ 仅使用键盘进行测试(拔掉鼠标) ✅ 使用屏幕阅读器进行测试(Windows 上用 NVDA,Mac 上用 VoiceOver) ✅ 使用正确的标题层级(h1 → h2 → h3,不要跳级) ✅ 用可见标签标记所有表单输入 ✅ 提供焦点指示器(绝不要只写 `outline: none`) ✅ 使用 `aria-live` 进行动态内容更新
### 切勿执行
❌ 使用带有 `onClick` 的 `div` 代替 `button` ❌ 在没有替代方案的情况下移除焦点轮廓 ❌ 仅使用颜色来传达信息 ❌ 使用占位符作为标签 ❌ 跳过标题级别(h1 → h3) ❌ 使用 `tabindex` > 0(会打乱自然顺序) ❌ 在存在语义化 HTML 时添加 ARIA ❌ 关闭对话框后忘记恢复焦点 ❌ 在可聚焦元素上使用 `role="presentation"` ❌ 创建键盘陷阱(无法退出)
---
## 已知问题预防
此技能可预防 **12** 个已记录的无障碍问题:
### 问题 #1:缺少焦点指示器
**错误**:交互元素没有可见的焦点指示器 **来源**:WCAG 2.4.7 (Focus Visible) **发生原因**:CSS 重置移除了默认轮廓 **预防**:始终提供自定义的 focus-visible 样式
### 问题 #2:颜色对比度不足
**错误**:文本对比度小于 4.5:1 **来源**:WCAG 1.4.3 (Contrast Minimum) **发生原因**:在白色背景上使用浅灰色文本 **预防**:使用对比度检查器测试所有文本颜色
### 问题 #3:缺少替代文本
**错误**:图片缺少 alt 属性 **来源**:WCAG 1.1.1 (Non-text Content) **发生原因**:忘记添加或认为这是可选的 **预防**:装饰性图片添加 alt="",有意义的图片添加描述性 alt
### 问题 #4:键盘导航失效
**错误**:交互元素无法通过键盘访问 **来源**:WCAG 2.1.1 (Keyboard) **发生原因**:使用 div onClick 代替 button **预防**:使用语义化交互元素(button, a)
### 问题 #5:表单输入缺少标签
**错误**:输入字段缺少关联的标签 **来源**:WCAG 3.3.2 (Labels or Instructions) **发生原因**:使用占位符作为标签 **预防**:始终使用带有 for/id 关联的 `<label>` 元素
### 问题 #6:跳过标题级别
**错误**:标题层级从 h1 跳到 h3 **来源**:WCAG 1.3.1 (Info and Relationships) **发生原因**:使用标题进行视觉样式设计而非语义 **预防**:按顺序使用标题,通过 CSS 进行样式设置
### 问题 #7:对话框中无焦点陷阱
**错误**:Tab 键跳出对话框进入背景内容 **来源**:WCAG 2.4.3 (Focus Order) **发生原因**:没有实现焦点陷阱 **预防**:为模态对话框实现焦点陷阱
### 问题 #8:动态内容缺少 aria-live
**错误**:屏幕阅读器不宣布更新 **来源**:WCAG 4.1.3 (Status Messages) **发生原因**:添加了动态内容但没有通知机制 **预防**:使用 aria-live="polite" 或 "assertive"
### 问题 #9:仅使用颜色传达信息
**错误**:仅使用颜色来传达状态 **来源**:WCAG 1.4.1 (Use of Color) **发生原因**:错误文本仅用红色表示,没有图标/文字 **预防**:添加图标 + 文本标签,不要只用颜色
### 问题 #10:非描述性链接文本
**错误**:链接文本为“点击这里”或“阅读更多” **来源**:WCAG 2.4.4 (Link Purpose) **发生原因**:没有上下文的通用链接文本 **预防**:使用描述性链接文本或 aria-label
### 问题 #11:自动播放媒体
**错误**:视频/音频在没有用户控制的情况下自动播放 **来源**:WCAG 1.4.2 (Audio Control) **发生原因**:使用 autoplay 属性但没有 controls **预防**:要求用户交互才能开始播放媒体
### 问题 #12:不可访问的自定义控件
**错误**:自定义下拉框/复选框不支持键盘 **来源**:WCAG 4.1.2 (Name, Role, Value) **发生原因**:使用 div 构建,没有 ARIA **预防**:使用原生元素或实现完整的 ARIA 模式
---
## WCAG 2.1 AA 快速检查清单
### 可感知
- [ ] 所有图片都有 alt 文本(如果是装饰性的,则为 alt="") - [ ] 文本对比度 ≥ 4.5:1(普通文本),≥ 3:1(大文本) - [ ] 不单独使用颜色来传达信息 - [ ] 文本可以放大到 200% 而不丢失内容 - [ ] 没有自动播放超过 3 秒的音频
### 可操作
- [ ] 所有功能均可通过键盘访问 - [ ] 没有键盘陷阱 - [ ] 可见的焦点指示器 - [ ] 用户可以暂停/停止/隐藏移动内容 - [ ] 页面标题描述了用途 - [ ] 焦点顺序符合逻辑 - [ ] 从文本或上下文中可以清楚看出链接用途 - [ ] 有多种查找页面的方式(菜单、搜索、站点地图) - [ ] 标题和标签描述了用途
### 可理解
- [ ] 指定了页面语言(`<html lang="zh-CN">`) - [ ] 标记了语言变化(`<span lang="es">`) - [ ] 焦点/输入时没有意外的上下文变化 - [ ] 站点导航一致 - [ ] 提供了表单标签/说明 - [ ] 识别并描述了输入错误 - [ ] 防止法律/财务/数据更改时的错误
### 健壮
- [ ] 有效的 HTML(无解析错误) - [ ] 所有 UI 组件都提供了名称、角色、值 - [ ] 识别了状态消息
---
## 测试工作流
### 1. 仅键盘测试(5 分钟)
``` 1. Unplug mouse or hide cursor 2. Tab through entire page - Can you reach all interactive elements? - Can you activate all buttons/links? - Is focus order logical? 3. Use Enter/Space to activate 4. Use Escape to close dialogs 5. Use arrow keys in menus/tabs ```
### 2. 屏幕阅读器测试(10 分钟)
**NVDA (Windows - 免费)**: - 下载:https://www.nvaccess.org/download/ - 启动:Ctrl+Alt+N - 导航:方向键或 Tab - 阅读:NVDA+下箭头 - 停止:NVDA+Q
**VoiceOver (Mac - 内置)**: - 启动:Cmd+F5 - 导航:VO+右/左箭头(VO = Ctrl+Option) - 阅读:VO+A(读取全部) - 停止:Cmd+F5
**测试内容:** - 是否宣布了所有交互元素? - 图片描述是否正确? - 输入时是否读取表单标签? - 是否宣布了动态更新? - 标题结构是否清晰?
### 3. 自动化测试
**axe DevTools**(浏览器扩展 - 强烈推荐): - 安装:Chrome/Firefox 扩展 - 运行:F12 → axe DevTools 标签页 → Scan - 修复:查看违规,按照补救指南操作 - 重测:修复后再次扫描
**Lighthouse**(Chrome 内置): - 打开 DevTools (F12) - Lighthouse 标签页 - 选择“无障碍”类别 - 生成报告 - 90 分以上为良好,100 分为理想
---
## 常见模式
### 模式 1:无障碍对话框/模态框
```typescript interface DialogProps { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode; }
function Dialog({ isOpen, onClose, title, children }: DialogProps) { const dialogRef = useRef<HTMLDivElement>(null);
useEffect(() => { if (!isOpen) return;
const previousFocus = document.activeElement as HTMLElement;
// Focus first focusable element const firstFocusable = dialogRef.current?.querySelector( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ) as HTMLElement; firstFocusable?.focus();
// Focus trap const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose(); } if (e.key === 'Tab') { const focusableElements = dialogRef.current?.querySelectorAll( 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' ); if (!focusableElements?.length) return;
const first = focusableElements[0] as HTMLElement; const last = focusableElements[focusableElements.length - 1] as HTMLElement;
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } };
document.addEventListener('keydown', handleKeyDown);
return () => { document.removeEventListener('keydown', handleKeyDown); previousFocus?.focus(); }; }, [isOpen, onClose]);
if (!isOpen) return null;
return ( <> {/* Backdrop */} <div className="dialog-backdrop" onClick={onClose} aria-hidden="true" />
{/* Dialog */} <div ref={dialogRef} role="dialog" aria-modal="true" aria-labelledby="dialog-title" className="dialog" > <h2 id="dialog-title">{title}</h2> <div className="dialog-content">{children}</div> <button onClick={onClose} aria-label="Close dialog">×</button> </div> </> ); } ```
**何时使用**:任何阻止与背景内容交互的模态对话框或覆盖层。
### 模式 2:无障碍选项卡
```typescript function Tabs({ tabs }: { tabs: Array<{ label: string; content: React.ReactNode }> }) { const [activeIndex, setActiveIndex] = useState(0);
const handleKeyDown = (e: React.KeyboardEvent, index: number) => { if (e.key === 'ArrowLeft') { e.preventDefault(); const newIndex = index === 0 ? tabs.length - 1 : index - 1; setActiveIndex(newIndex); } else if (e.key === 'ArrowRight') { e.preventDefault(); const newIndex = index === tabs.length - 1 ? 0 : index + 1; setActiveIndex(newIndex); } else if (e.key === 'Home') { e.preventDefault(); setActiveIndex(0); } else if (e.key === 'End') { e.preventDefault(); setActiveIndex(tabs.length - 1); } };
return ( <div> <div role="tablist" aria-label="Content tabs"> {tabs.map((tab, index) => ( <button key={index} role="tab" aria-selected={activeIndex === index} aria-controls={`panel-${index}`} id={`tab-${index}`} tabIndex={activeIndex === index ? 0 : -1} onClick={() => setActiveIndex(index)} onKeyDown={(e) => handleKeyDown(e, index)} > {tab.label} </button> ))} </div> {tabs.map((tab, index) => ( <div key={index} role="tabpanel" id={`panel-${index}`} aria-labelledby={`tab-${index}`} hidden={activeIndex !== index} tabIndex={0} > {tab.content} </div> ))} </div> ); } ```
**何时使用**:具有多个面板的选项卡界面。
### 模式 3:跳过链接
```html <!-- Place at very top of body --> <a href="#main-content" class="skip-link"> Skip to main content </a>
<style> .skip-link { position: absolute; top: -40px; left: 0; background: var(--primary); color: white; padding: 8px 16px; z-index: 9999; }
.skip-link:focus { top: 0; } </style>
<!-- Then in your layout --> <main id="main-content" tabindex="-1"> <!-- Page content --> </main> ```
**何时使用**:所有在主要内容之前有导航/标头的多页网站。
### 模式 4:带验证的无障碍表单
```typescript function ContactForm() { const [errors, setErrors] = useState<Record<string, string>>({}); const [touched, setTouched] = useState<Record<string, boolean>>({});
const validateEmail = (email: string) => { if (!email) return 'Email is required'; if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) return 'Email is invalid'; return ''; };
const handleBlur = (field: string, value: string) => { setTouched(prev => ({ ...prev, [field]: true })); const error = validateEmail(value); setErrors(prev => ({ ...prev, [field]: error })); };
return ( <form> <div> <label htmlFor="email">Email address *</label> <input type="email" id="email" name="email" required aria-required="true" aria-invalid={touched.email && !!errors.email} aria-describedby={errors.email ? 'email-error' : undefined} onBlur={(e) => handleBlur('email', e.target.value)} /> {touched.email && errors.email && ( <span id="email-error" role="alert" className="error"> {errors.email} </span> )} </div>
<button type="submit">Submit</button>
{/* Global form error */} <div role="alert" aria-live="assertive" aria-atomic="true"> {/* Dynamic error message appears here */} </div> </form> ); } ```
**何时使用**:所有带验证的表单。
---
## 使用捆绑资源
### 参考资料
深入研究的详细文档:
- **wcag-checklist.md** - 包含示例的完整 WCAG 2.1 A 级与 AA 级要求 - **semantic-html.md** - 元素选择指南,何时使用哪个标签 - **aria-patterns.md** - ARIA 角色、状态、属性及何时使用它们 - **focus-management.md** - 焦点顺序、焦点陷阱、焦点恢复模式 - **color-contrast.md** - 对比度要求、测试工具、调色板建议 - **forms-validation.md** - 无障碍表单模式、错误处理、通告
**何时加载这些内容**: - 用户请求完整的 WCAG 检查清单 - 深入探讨特定模式(标签页、手风琴等) - 颜色对比度问题或调色板设计 - 复杂的表单验证场景
### 智能体
- **a11y-auditor.md** - 检查页面违规情况的自动化无障碍审计器
**何时使用**:请求对现有页面/组件进行无障碍审计。
---
## 高级主题
### ARIA 实时区域
三种礼貌级别:
```html <!-- Polite: Wait for screen reader to finish current announcement --> <div aria-live="polite">New messages: 3</div>
<!-- Assertive: Interrupt immediately --> <div aria-live="assertive" role="alert"> Error: Form submission failed </div>
<!-- Off: Don't announce (default) --> <div aria-live="off">Loading...</div> ```
**最佳实践:** - 对非关键更新(通知、计数器)使用 `polite` - 对错误和关键警报使用 `assertive` - 使用 `aria-atomic="true"` 在变更时读取整个区域 - 保持信息简短且有意义
### SPA 中的焦点管理
React Router 不会在导航时重置焦点——你需要手动处理:
```typescript function App() { const location = useLocation(); const mainRef = useRef<HTMLElement>(null);
useEffect(() => { // Focus main content on route change mainRef.current?.focus(); // Announce page title to screen readers const title = document.title; const announcement = document.createElement('div'); announcement.setAttribute('role', 'status'); announcement.setAttribute('aria-live', 'polite'); announcement.textContent = `Navigated to ${title}`; document.body.appendChild(announcement); setTimeout(() => announcement.remove(), 1000); }, [location.pathname]);
return <main ref={mainRef} tabIndex={-1} id="main-content">...</main>; } ```
### 无障碍数据表格
```html <table> <caption>Monthly sales by region</caption> <thead> <tr> <th scope="col">Region</th> <th scope="col">Q1</th> <th scope="col">Q2</th> </tr> </thead> <tbody> <tr> <th scope="row">North</th> <td>$10,000</td> <td>$12,000</td> </tr> </tbody> </table> ```
**关键属性:** - `<caption>` - 描述表格用途 - `scope="col"` - 标识列标题 - `scope="row"` - 标识行标题 - 将数据单元格与标题关联,供屏幕阅读器使用
---
## 官方文档
- **WCAG 2.1**: https://www.w3.org/WAI/WCAG21/quickref/ - **MDN Accessibility**: https://developer.mozilla.org/en-US/docs/Web/Accessibility - **ARIA Authoring Practices**: https://www.w3.org/WAI/ARIA/apg/ - **WebAIM**: https://webaim.org/articles/ - **axe DevTools**: https://www.deque.com/axe/devtools/
---
## 故障排除
### 问题:焦点指示器不可见
**症状**:可以通过 Tab 键浏览页面,但看不到焦点所在位置 **原因**:CSS 移除了轮廓线或对比度不足 **解决方案**: ```css *:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } ```
### 问题:屏幕阅读器未播报更新
**症状**:动态内容发生变化但没有播报 **原因**:没有 aria-live 区域 **解决方案**:将动态内容包裹在 `<div aria-live="polite">` 中或使用 role="alert"
### 问题:对话框焦点逃逸到背景
**症状**:Tab 键导航到了对话框背后的元素 **原因**:没有焦点陷阱 **解决方案**:实现焦点陷阱(参见上面的模式 1)
### 问题:表单错误未被播报
**症状**:出现了视觉错误,但屏幕阅读器未察觉 **原因**:没有 aria-invalid 或 role="alert" **解决方案**:使用 aria-invalid + aria-describedby 指向带有 role="alert" 的错误消息
---
## 完整设置检查清单
用于每个页面/组件:
- [ ] 所有交互元素均可通过键盘访问 - [ ] 所有可聚焦元素上都有可见的焦点指示器 - [ ] 图片具有 alt 文本(如果是装饰性的则使用 alt="") - [ ] 文本对比度 ≥ 4.5:1(使用 axe 或 Lighthouse 测试) - [ ] 表单输入具有关联的标签(不仅仅是占位符) - [ ] 标题层级符合逻辑(无跳级) - [ ] 页面具有 `<html lang="en">` 或适当的语言属性 - [ ] 对话框具有焦点陷阱,并在关闭时恢复焦点 - [ ] 动态内容使用 aria-live 或 role="alert" - [ ] 不仅使用颜色来传达信息 - [ ] 仅使用键盘进行测试(不使用鼠标) - [ ] 使用屏幕阅读器测试(NVDA 或 VoiceOver) - [ ] 运行 axe DevTools 扫描(0 个违规) - [ ] Lighthouse 无障碍评分 ≥ 90
---
**有问题?发现错误?**
1. 查看 `references/wcag-checklist.md` 了解完整要求 2. 使用 `/a11y-auditor` 智能体扫描你的页面 3. 运行 axe DevTools 进行自动化测试 4. 使用实际键盘 + 屏幕阅读器进行测试
---
**标准**:WCAG 2.1 AA 级 **测试工具**:axe DevTools, Lighthouse, NVDA, VoiceOver **成功标准**:Lighthouse 评分 90+,0 个严重违规