accessibility

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Web Accessibility (WCAG 2.1 AA)

网页无障碍(WCAG 2.1 AA)

Status: Production Ready ✅ Last Updated: 2026-01-14 Dependencies: None (framework-agnostic) Standards: WCAG 2.1 Level AA

状态: 可用于生产环境 ✅ 最后更新: 2026-01-14 依赖项: 无(与框架无关) 遵循标准: WCAG 2.1 Level AA

Quick Start (5 Minutes)

快速入门(5分钟)

1. Semantic HTML Foundation

1. 语义化HTML基础

Choose the right element - don't use
div
for everything:
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>
Why this matters:
  • Semantic elements have built-in keyboard support
  • Screen readers announce role automatically
  • Browser provides default accessible behaviors
选择合适的元素——不要什么都用
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. Focus Management

2. 焦点管理

Make interactive elements keyboard-accessible:
css
/* ❌ WRONG - removes focus outline */
button:focus { outline: none; }

/* ✅ CORRECT - custom accessible outline */
button:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
CRITICAL:
  • Never remove focus outlines without replacement
  • Use
    :focus-visible
    to show only on keyboard focus
  • Ensure 3:1 contrast ratio for focus indicators
确保交互元素可通过键盘访问:
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. Text Alternatives

3. 文本替代方案

Every non-text element needs a text alternative:
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>

每个非文本元素都需要文本替代:
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>

The 5-Step Accessibility Process

五步无障碍开发流程

Step 1: Choose Semantic HTML

步骤1:选择语义化HTML

Decision tree for element selection:
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>
See
references/semantic-html.md
for complete guide.
元素选择决策树:
需要可点击元素?
├─ 跳转到其他页面? → <a href="...">
├─ 提交表单? → <button type="submit">
├─ 打开对话框? → <button aria-haspopup="dialog">
└─ 其他操作? → <button type="button">

需要分组内容?
├─ 独立文章? → <article>
├─ 主题章节? → <section>
├─ 导航链接? → <nav>
└─ 补充信息? → <aside>

表单元素?
├─ 文本输入框? → <input type="text">
├─ 多选选项? → <select> 或 <input type="radio">
├─ 切换按钮? → <input type="checkbox"> 或 <button aria-pressed>
└─ 长文本输入? → <textarea>
完整指南请参考
references/semantic-html.md

Step 2: Add ARIA When Needed

步骤2:必要时添加ARIA

Golden rule: Use ARIA only when HTML can't express the pattern.
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>
Common ARIA patterns:
  • aria-label
    - When visible label doesn't exist
  • aria-labelledby
    - Reference existing text as label
  • aria-describedby
    - Additional description
  • aria-live
    - Announce dynamic updates
  • aria-expanded
    - Collapsible/expandable state
See
references/aria-patterns.md
for complete patterns.
黄金法则:仅当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

Step 3: Implement Keyboard Navigation

步骤3:实现键盘导航

All interactive elements must be keyboard-accessible:
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>;
}
Essential keyboard patterns:
  • Tab/Shift+Tab: Navigate between focusable elements
  • Enter/Space: Activate buttons/links
  • Arrow keys: Navigate within components (tabs, menus)
  • Escape: Close dialogs/menus
  • Home/End: Jump to first/last item
See
references/focus-management.md
for complete patterns.
所有交互元素必须支持键盘访问:
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

Step 4: Ensure Color Contrast

步骤4:确保颜色对比度

WCAG AA requirements:
  • Normal text (under 18pt): 4.5:1 contrast ratio
  • Large text (18pt+ or 14pt+ bold): 3:1 contrast ratio
  • UI components (buttons, borders): 3:1 contrast ratio
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 */
}
Testing tools:
  • Browser DevTools (Chrome/Firefox have built-in checkers)
  • Contrast checker extensions
  • axe DevTools extension
See
references/color-contrast.md
for complete guide.
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 */
}
测试工具:
  • 浏览器开发者工具(Chrome/Firefox内置检查器)
  • 对比度检查器扩展
  • axe DevTools扩展
完整指南请参考
references/color-contrast.md

Step 5: Make Forms Accessible

步骤5:实现无障碍表单

Every form input needs a visible label:
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">
Error handling:
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 for dynamic errors:
html
<div role="alert" aria-live="assertive" aria-atomic="true">
  Form submission failed. Please fix the errors above.
</div>
See
references/forms-validation.md
for complete patterns.

每个表单输入框都需要可见标签:
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>
动态错误的实时区域:
html
<div role="alert" aria-live="assertive" aria-atomic="true">
  Form submission failed. Please fix the errors above.
</div>
完整模式请参考
references/forms-validation.md

Critical Rules

关键规则

Always Do

必须遵守

✅ Use semantic HTML elements first (button, a, nav, article, etc.) ✅ Provide text alternatives for all non-text content ✅ Ensure 4.5:1 contrast for normal text, 3:1 for large text/UI ✅ Make all functionality keyboard accessible ✅ Test with keyboard only (unplug mouse) ✅ Test with screen reader (NVDA on Windows, VoiceOver on Mac) ✅ Use proper heading hierarchy (h1 → h2 → h3, no skipping) ✅ Label all form inputs with visible labels ✅ Provide focus indicators (never just
outline: none
) ✅ Use
aria-live
for dynamic content updates
✅ 优先使用语义化HTML元素(button、a、nav、article等) ✅ 为所有非文本内容提供文本替代方案 ✅ 确保普通文本对比度≥4.5:1,大文本/UI组件≥3:1 ✅ 所有功能都支持键盘访问 ✅ 仅使用键盘进行测试(拔掉鼠标) ✅ 使用屏幕阅读器测试(Windows用NVDA,Mac用VoiceOver) ✅ 使用正确的标题层级(h1 → h2 → h3,不要跳过层级) ✅ 为所有表单输入框添加可见标签 ✅ 提供焦点指示器(绝对不要只写
outline: none
) ✅ 使用
aria-live
处理动态内容更新

Never Do

绝对禁止

❌ Use
div
with
onClick
instead of
button
❌ Remove focus outlines without replacement ❌ Use color alone to convey information ❌ Use placeholders as labels ❌ Skip heading levels (h1 → h3) ❌ Use
tabindex
> 0 (messes with natural order) ❌ Add ARIA when semantic HTML exists ❌ Forget to restore focus after closing dialogs ❌ Use
role="presentation"
on focusable elements ❌ Create keyboard traps (no way to escape)

❌ 用带
onClick
div
代替
button
❌ 移除焦点轮廓而不提供替代样式 ❌ 仅使用颜色传达信息 ❌ 用占位符代替标签 ❌ 跳过标题层级(h1 → h3) ❌ 使用
tabindex
> 0(会打乱自然导航顺序) ❌ 当语义化HTML可用时仍添加ARIA ❌ 关闭对话框后忘记恢复焦点 ❌ 在可聚焦元素上使用
role="presentation"
❌ 创建键盘陷阱(无法退出)

Known Issues Prevention

已知问题预防

This skill prevents 12 documented accessibility issues:
本技能可预防12种已记录的无障碍问题:

Issue #1: Missing Focus Indicators

问题#1:缺失焦点指示器

Error: Interactive elements have no visible focus indicator Source: WCAG 2.4.7 (Focus Visible) Why It Happens: CSS reset removes default outline Prevention: Always provide custom focus-visible styles
错误:交互元素没有可见的焦点指示器 来源:WCAG 2.4.7(焦点可见性) 原因:CSS重置移除了默认轮廓 解决方法:始终自定义:focus-visible样式

Issue #2: Insufficient Color Contrast

问题#2:颜色对比度不足

Error: Text has less than 4.5:1 contrast ratio Source: WCAG 1.4.3 (Contrast Minimum) Why It Happens: Using light gray text on white background Prevention: Test all text colors with contrast checker
错误:文本对比度低于4.5:1 来源:WCAG 1.4.3(最低对比度) 原因:在白色背景上使用浅灰色文本 解决方法:用对比度检查器测试所有文本颜色

Issue #3: Missing Alt Text

问题#3:缺失替代文本

Error: Images missing alt attributes Source: WCAG 1.1.1 (Non-text Content) Why It Happens: Forgot to add or thought it was optional Prevention: Add alt="" for decorative, descriptive alt for meaningful images
错误:图片缺少alt属性 来源:WCAG 1.1.1(非文本内容) 原因:忘记添加或认为可选 解决方法:装饰性图片用alt="",有意义的图片添加描述性alt文本

Issue #4: Keyboard Navigation Broken

问题#4:键盘导航失效

Error: Interactive elements not reachable by keyboard Source: WCAG 2.1.1 (Keyboard) Why It Happens: Using div onClick instead of button Prevention: Use semantic interactive elements (button, a)
错误:交互元素无法通过键盘访问 来源:WCAG 2.1.1(键盘访问) 原因:用div onClick代替button 解决方法:使用语义化交互元素(button、a)

Issue #5: Form Inputs Without Labels

问题#5:表单输入框无标签

Error: Input fields missing associated labels Source: WCAG 3.3.2 (Labels or Instructions) Why It Happens: Using placeholder as label Prevention: Always use
<label>
element with for/id association
错误:输入框缺少关联标签 来源:WCAG 3.3.2(标签或说明) 原因:用占位符代替标签 解决方法:始终使用带for/id关联的
<label>
元素

Issue #6: Skipped Heading Levels

问题#6:跳过标题层级

Error: Heading hierarchy jumps from h1 to h3 Source: WCAG 1.3.1 (Info and Relationships) Why It Happens: Using headings for visual styling instead of semantics Prevention: Use headings in order, style with CSS
错误:标题层级从h1直接跳到h3 来源:WCAG 1.3.1(信息与关系) 原因:为了视觉样式而使用标题标签 解决方法:按顺序使用标题,用CSS控制样式

Issue #7: No Focus Trap in Dialogs

问题#7:对话框无焦点陷阱

Error: Tab key exits dialog to background content Source: WCAG 2.4.3 (Focus Order) Why It Happens: No focus trap implementation Prevention: Implement focus trap for modal dialogs
错误:Tab键跳出对话框到背景内容 来源:WCAG 2.4.3(焦点顺序) 原因:未实现焦点陷阱 解决方法:为模态对话框实现焦点陷阱

Issue #8: Missing aria-live for Dynamic Content

问题#8:动态内容缺失aria-live

Error: Screen reader doesn't announce updates Source: WCAG 4.1.3 (Status Messages) Why It Happens: Dynamic content added without announcement Prevention: Use aria-live="polite" or "assertive"
错误:屏幕阅读器不播报内容更新 来源:WCAG 4.1.3(状态消息) 原因:添加动态内容时未设置播报 解决方法:使用aria-live="polite"或"assertive"

Issue #9: Color-Only Information

问题#9:仅用颜色传达信息

Error: Using only color to convey status Source: WCAG 1.4.1 (Use of Color) Why It Happens: Red text for errors without icon/text Prevention: Add icon + text label, not just color
错误:仅使用颜色传达状态 来源:WCAG 1.4.1(颜色使用) 原因:仅用红色文本表示错误,未加图标/文字 解决方法:添加图标+文字标签,不要仅依赖颜色

Issue #10: Non-descriptive Link Text

问题#10:链接文本无描述性

Error: Links with "click here" or "read more" Source: WCAG 2.4.4 (Link Purpose) Why It Happens: Generic link text without context Prevention: Use descriptive link text or aria-label
错误:链接使用“点击这里”或“阅读更多” 来源:WCAG 2.4.4(链接目的) 原因:使用通用链接文本,缺少上下文 解决方法:使用描述性链接文本或aria-label

Issue #11: Auto-playing Media

问题#11:媒体自动播放

Error: Video/audio auto-plays without user control Source: WCAG 1.4.2 (Audio Control) Why It Happens: Autoplay attribute without controls Prevention: Require user interaction to start media
错误:视频/音频自动播放,无用户控制 来源:WCAG 1.4.2(音频控制) 原因:使用autoplay属性但未提供控制 解决方法:需要用户交互才能启动媒体

Issue #12: Inaccessible Custom Controls

问题#12:自定义控件无障碍性差

Error: Custom select/checkbox without keyboard support Source: WCAG 4.1.2 (Name, Role, Value) Why It Happens: Building from divs without ARIA Prevention: Use native elements or implement full ARIA pattern

错误:自定义选择框/复选框不支持键盘 来源:WCAG 4.1.2(名称、角色、值) 原因:用div构建控件但未添加ARIA 解决方法:使用原生元素或完整实现ARIA模式

WCAG 2.1 AA Quick Checklist

WCAG 2.1 AA快速检查清单

Perceivable

可感知性

  • All images have alt text (or alt="" if decorative)
  • Text contrast ≥ 4.5:1 (normal), ≥ 3:1 (large)
  • Color not used alone to convey information
  • Text can be resized to 200% without loss of content
  • No auto-playing audio >3 seconds
  • 所有图片都有alt文本(装饰性图片用alt="")
  • 文本对比度≥4.5:1(普通文本),≥3:1(大文本)
  • 不单独使用颜色传达信息
  • 文本可放大到200%且不丢失内容
  • 无自动播放超过3秒的音频

Operable

可操作性

  • All functionality keyboard accessible
  • No keyboard traps
  • Visible focus indicators
  • Users can pause/stop/hide moving content
  • Page titles describe purpose
  • Focus order is logical
  • Link purpose clear from text or context
  • Multiple ways to find pages (menu, search, sitemap)
  • Headings and labels describe purpose
  • 所有功能都支持键盘访问
  • 无键盘陷阱
  • 有可见的焦点指示器
  • 用户可暂停/停止/隐藏动态内容
  • 页面标题能描述页面用途
  • 焦点顺序符合逻辑
  • 链接目的可从文本或上下文中明确
  • 有多种方式查找页面(菜单、搜索、站点地图)
  • 标题和标签能描述用途

Understandable

可理解性

  • Page language specified (
    <html lang="en">
    )
  • Language changes marked (
    <span lang="es">
    )
  • No unexpected context changes on focus/input
  • Consistent navigation across site
  • Form labels/instructions provided
  • Input errors identified and described
  • Error prevention for legal/financial/data changes
  • 指定了页面语言(
    <html lang="en">
  • 标记了语言变化(
    <span lang="es">
  • 聚焦/输入时无意外的上下文变化
  • 站点导航保持一致
  • 提供了表单标签/说明
  • 识别并描述了输入错误
  • 对法律/财务/数据变更提供错误预防机制

Robust

健壮性

  • Valid HTML (no parsing errors)
  • Name, role, value available for all UI components
  • Status messages identified (aria-live)

  • HTML有效(无解析错误)
  • 所有UI组件的名称、角色、值可被识别
  • 状态消息已标记(aria-live)

Testing Workflow

测试流程

1. Keyboard-Only Testing (5 minutes)

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
1. 拔掉鼠标或隐藏光标
2. 按Tab键遍历整个页面
   - 能否到达所有交互元素?
   - 能否激活所有按钮/链接?
   - 焦点顺序是否符合逻辑?
3. 使用Enter/Space激活元素
4. 使用Escape关闭对话框
5. 在菜单/标签页中使用方向键

2. Screen Reader Testing (10 minutes)

2. 屏幕阅读器测试(10分钟)

NVDA (Windows - Free):
VoiceOver (Mac - Built-in):
  • Start: Cmd+F5
  • Navigate: VO+Right/Left arrow (VO = Ctrl+Option)
  • Read: VO+A (read all)
  • Stop: Cmd+F5
What to test:
  • Are all interactive elements announced?
  • Are images described properly?
  • Are form labels read with inputs?
  • Are dynamic updates announced?
  • Is heading structure clear?
NVDA(Windows - 免费):
VoiceOver(Mac - 内置):
  • 启动:Cmd+F5
  • 导航:VO+左右箭头(VO = Ctrl+Option)
  • 朗读:VO+A(朗读全部)
  • 停止:Cmd+F5
测试要点:
  • 所有交互元素是否被正确播报?
  • 图片是否被正确描述?
  • 表单标签是否与输入框一起被朗读?
  • 动态更新是否被播报?
  • 标题结构是否清晰?

3. Automated Testing

3. 自动化测试

axe DevTools (Browser extension - highly recommended):
  • Install: Chrome/Firefox extension
  • Run: F12 → axe DevTools tab → Scan
  • Fix: Review violations, follow remediation
  • Retest: Scan again after fixes
Lighthouse (Built into Chrome):
  • Open DevTools (F12)
  • Lighthouse tab
  • Select "Accessibility" category
  • Generate report
  • Score 90+ is good, 100 is ideal

axe DevTools(浏览器扩展 - 强烈推荐):
  • 安装:Chrome/Firefox扩展
  • 运行:F12 → axe DevTools标签 → 扫描
  • 修复:查看违规项,按照建议修复
  • 重新测试:修复后再次扫描
Lighthouse(Chrome内置):
  • 打开开发者工具(F12)
  • 切换到Lighthouse标签
  • 选择“无障碍”分类
  • 生成报告
  • 90分以上为良好,100分为理想

Common Patterns

常用模式

Pattern 1: Accessible Dialog/Modal

模式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>
    </>
  );
}
When to use: Any modal dialog or overlay that blocks interaction with background content.
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>
    </>
  );
}
适用场景: 任何会阻止背景内容交互的模态对话框或遮罩层。

Pattern 2: Accessible Tabs

模式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>
  );
}
When to use: Tabbed interface with multiple panels.
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>
  );
}
适用场景: 包含多个面板的标签页界面。

Pattern 3: Skip Links

模式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>
When to use: All multi-page websites with navigation/header before main content.
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>
适用场景: 所有在主内容前有导航/页眉的多页面网站。

Pattern 4: Accessible Form with Validation

模式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>
  );
}
When to use: All forms with validation.

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>
  );
}
适用场景: 所有带验证的表单。

Using Bundled Resources

使用捆绑资源

References (references/)

参考文档(references/)

Detailed documentation for deep dives:
  • wcag-checklist.md - Complete WCAG 2.1 Level A & AA requirements with examples
  • semantic-html.md - Element selection guide, when to use which tag
  • aria-patterns.md - ARIA roles, states, properties, and when to use them
  • focus-management.md - Focus order, focus traps, focus restoration patterns
  • color-contrast.md - Contrast requirements, testing tools, color palette tips
  • forms-validation.md - Accessible form patterns, error handling, announcements
When Claude should load these:
  • User asks for complete WCAG checklist
  • Deep dive into specific pattern (tabs, accordions, etc.)
  • Color contrast issues or palette design
  • Complex form validation scenarios
深入学习的详细文档:
  • wcag-checklist.md - 完整的WCAG 2.1 Level A & AA要求及示例
  • semantic-html.md - 元素选择指南,何时使用哪种标签
  • aria-patterns.md - ARIA角色、状态、属性及使用场景
  • focus-management.md - 焦点顺序、焦点陷阱、焦点恢复模式
  • color-contrast.md - 对比度要求、测试工具、调色板技巧
  • forms-validation.md - 无障碍表单模式、错误处理、消息播报
Claude应加载这些文档的场景:
  • 用户询问完整的WCAG检查清单
  • 深入探讨特定模式(标签页、折叠面板等)
  • 颜色对比度问题或调色板设计
  • 复杂表单验证场景

Agents (agents/)

代理工具(agents/)

  • a11y-auditor.md - Automated accessibility auditor that checks pages for violations
When to use: Request accessibility audit of existing page/component.

  • a11y-auditor.md - 自动化无障碍审计工具,检查页面是否存在违规
使用场景: 请求对现有页面/组件进行无障碍审计。

Advanced Topics

高级主题

ARIA Live Regions

ARIA实时区域

Three politeness levels:
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>
Best practices:
  • Use
    polite
    for non-critical updates (notifications, counters)
  • Use
    assertive
    for errors and critical alerts
  • Use
    aria-atomic="true"
    to read entire region on change
  • Keep messages concise and meaningful
三种播报优先级:
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"
    在内容变化时朗读整个区域
  • 消息要简洁且有意义

Focus Management in SPAs

单页应用(SPA)中的焦点管理

React Router doesn't reset focus on navigation - you need to handle it:
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>;
}
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>;
}

Accessible Data Tables

无障碍数据表格

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>
Key attributes:
  • <caption>
    - Describes table purpose
  • scope="col"
    - Identifies column headers
  • scope="row"
    - Identifies row headers
  • Associates data cells with headers for screen readers

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"
    - 标识行标题
  • 为屏幕阅读器关联数据单元格与标题

Official Documentation

官方文档

Troubleshooting

故障排除

Problem: Focus indicators not visible

问题:焦点指示器不可见

Symptoms: Can tab through page but don't see where focus is Cause: CSS removed outlines or insufficient contrast Solution:
css
*:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
症状: 可遍历页面但看不到焦点位置 原因: CSS移除了轮廓或对比度不足 解决方案:
css
*:focus-visible {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}

Problem: Screen reader not announcing updates

问题:屏幕阅读器不播报更新

Symptoms: Dynamic content changes but no announcement Cause: No aria-live region Solution: Wrap dynamic content in
<div aria-live="polite">
or use role="alert"
症状: 动态内容变化但无播报 原因: 未设置aria-live区域 解决方案: 将动态内容包裹在
<div aria-live="polite">
中,或使用role="alert"

Problem: Dialog focus escapes to background

问题:对话框焦点跳转到背景

Symptoms: Tab key navigates to elements behind dialog Cause: No focus trap Solution: Implement focus trap (see Pattern 1 above)
症状: Tab键导航到对话框后方的元素 原因: 未实现焦点陷阱 解决方案: 实现焦点陷阱(参考上方模式1)

Problem: Form errors not announced

问题:表单错误未被播报

Symptoms: Visual errors appear but screen reader doesn't notice Cause: No aria-invalid or role="alert" Solution: Use aria-invalid + aria-describedby pointing to error message with role="alert"

症状: 显示视觉错误但屏幕阅读器未察觉 原因: 未使用aria-invalid或role="alert" 解决方案: 使用aria-invalid + aria-describedby指向带role="alert"的错误消息

Complete Setup Checklist

完整设置检查清单

Use this for every page/component:
  • All interactive elements are keyboard accessible
  • Visible focus indicators on all focusable elements
  • Images have alt text (or alt="" if decorative)
  • Text contrast ≥ 4.5:1 (test with axe or Lighthouse)
  • Form inputs have associated labels (not just placeholders)
  • Heading hierarchy is logical (no skipped levels)
  • Page has
    <html lang="en">
    or appropriate language
  • Dialogs have focus trap and restore focus on close
  • Dynamic content uses aria-live or role="alert"
  • Color not used alone to convey information
  • Tested with keyboard only (no mouse)
  • Tested with screen reader (NVDA or VoiceOver)
  • Ran axe DevTools scan (0 violations)
  • Lighthouse accessibility score ≥ 90

Questions? Issues?
  1. Check
    references/wcag-checklist.md
    for complete requirements
  2. Use
    /a11y-auditor
    agent to scan your page
  3. Run axe DevTools for automated testing
  4. Test with actual keyboard + screen reader

Standards: WCAG 2.1 Level AA Testing Tools: axe DevTools, Lighthouse, NVDA, VoiceOver Success Criteria: 90+ Lighthouse score, 0 critical violations
为每个页面/组件使用此清单:
  • 所有交互元素都支持键盘访问
  • 所有可聚焦元素都有可见的焦点指示器
  • 图片都有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 Level AA 测试工具: axe DevTools, Lighthouse, NVDA, VoiceOver 成功标准: Lighthouse评分≥90,0个关键违规