ClawSkills logoClawSkills

Accessibility

构建符合 WCAG 2.1 AA 标准的网站,具备语义化 HTML、适当的 ARIA、焦点管理和屏幕阅读器支持。包括颜色对比度(4.5:1 文本)、键

介绍

# 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 个严重违规

更多产品