accessibility
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWeb 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 for everything:
divhtml
<!-- ❌ 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
选择合适的元素——不要什么都用:
divhtml
<!-- ❌ 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 to show only on keyboard focus
:focus-visible - 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 for complete guide.
references/semantic-html.md元素选择决策树:
需要可点击元素?
├─ 跳转到其他页面? → <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.mdStep 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:
- - When visible label doesn't exist
aria-label - - Reference existing text as label
aria-labelledby - - Additional description
aria-describedby - - Announce dynamic updates
aria-live - - Collapsible/expandable state
aria-expanded
See for complete patterns.
references/aria-patterns.md黄金法则:仅当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.mdStep 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 for complete patterns.
references/focus-management.md所有交互元素必须支持键盘访问:
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.mdStep 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 for complete guide.
references/color-contrast.mdWCAG 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.mdStep 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 for complete patterns.
references/forms-validation.md每个表单输入框都需要可见标签:
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.mdCritical 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 )
✅ Use for dynamic content updates
outline: nonearia-live✅ 优先使用语义化HTML元素(button、a、nav、article等)
✅ 为所有非文本内容提供文本替代方案
✅ 确保普通文本对比度≥4.5:1,大文本/UI组件≥3:1
✅ 所有功能都支持键盘访问
✅ 仅使用键盘进行测试(拔掉鼠标)
✅ 使用屏幕阅读器测试(Windows用NVDA,Mac用VoiceOver)
✅ 使用正确的标题层级(h1 → h2 → h3,不要跳过层级)
✅ 为所有表单输入框添加可见标签
✅ 提供焦点指示器(绝对不要只写)
✅ 使用处理动态内容更新
outline: nonearia-liveNever Do
绝对禁止
❌ Use with instead of
❌ Remove focus outlines without replacement
❌ Use color alone to convey information
❌ Use placeholders as labels
❌ Skip heading levels (h1 → h3)
❌ Use > 0 (messes with natural order)
❌ Add ARIA when semantic HTML exists
❌ Forget to restore focus after closing dialogs
❌ Use on focusable elements
❌ Create keyboard traps (no way to escape)
divonClickbuttontabindexrole="presentation"❌ 用带的代替
❌ 移除焦点轮廓而不提供替代样式
❌ 仅使用颜色传达信息
❌ 用占位符代替标签
❌ 跳过标题层级(h1 → h3)
❌ 使用 > 0(会打乱自然导航顺序)
❌ 当语义化HTML可用时仍添加ARIA
❌ 关闭对话框后忘记恢复焦点
❌ 在可聚焦元素上使用
❌ 创建键盘陷阱(无法退出)
onClickdivbuttontabindexrole="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 element with for/id association
<label>错误:输入框缺少关联标签
来源: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/tabs1. 拔掉鼠标或隐藏光标
2. 按Tab键遍历整个页面
- 能否到达所有交互元素?
- 能否激活所有按钮/链接?
- 焦点顺序是否符合逻辑?
3. 使用Enter/Space激活元素
4. 使用Escape关闭对话框
5. 在菜单/标签页中使用方向键2. Screen Reader Testing (10 minutes)
2. 屏幕阅读器测试(10分钟)
NVDA (Windows - Free):
- Download: https://www.nvaccess.org/download/
- Start: Ctrl+Alt+N
- Navigate: Arrow keys or Tab
- Read: NVDA+Down arrow
- Stop: NVDA+Q
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 - 免费):
- 下载: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. 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 for non-critical updates (notifications, counters)
polite - Use for errors and critical alerts
assertive - Use to read entire region on change
aria-atomic="true" - 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:
- - Describes table purpose
<caption> - - Identifies column headers
scope="col" - - Identifies row headers
scope="row" - 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
官方文档
- 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/
- 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/
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 or use role="alert"
<div aria-live="polite">症状: 动态内容变化但无播报
原因: 未设置aria-live区域
解决方案: 将动态内容包裹在中,或使用role="alert"
<div aria-live="polite">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 or appropriate language
<html lang="en"> - 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?
- Check for complete requirements
references/wcag-checklist.md - Use agent to scan your page
/a11y-auditor - Run axe DevTools for automated testing
- 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
有疑问?遇到问题?
- 查看获取完整要求
references/wcag-checklist.md - 使用代理工具扫描你的页面
/a11y-auditor - 运行axe DevTools进行自动化测试
- 用真实键盘+屏幕阅读器测试
遵循标准: WCAG 2.1 Level AA
测试工具: axe DevTools, Lighthouse, NVDA, VoiceOver
成功标准: Lighthouse评分≥90,0个关键违规