关于 accessibility
accessibility 是面向网页设计与开发者的无障碍实践技能,围绕 WCAG 2.1 AA 提供跨框架指南。内容涵盖语义 HTML、键盘操作、对比度、表单和测试流程。
主要功能
- 01语义 HTML 与 ARIA 使用指南
- 02键盘导航和焦点管理
- 03WCAG AA 色彩对比要求
- 04可访问表单与错误提示
- 05键盘、屏幕阅读器及自动化测试
适用场景
- 01检查网页是否符合 WCAG 2.1 AA
- 02改进对话框、标签页和表单的可访问性
- 03为键盘与屏幕阅读器用户调整交互
- 04通过自动化工具检查无障碍问题
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ jezweb/ claude-skills --skill accessibility安装后在 Claude Code 中输入/accessibility确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
README.md.claude-plugin/plugin.jsonagents/a11y-auditor.mdreferences/aria-patterns.mdcolor-contrast.mdfocus-management.mdforms-validation.mdsemantic-html.mdwcag-checklist.mdrules/accessibility.md详细介绍
Web Accessibility (WCAG 2.1 AA)
状态:已可投入生产 ✅ 最后更新:2026-01-14 依赖项:无(与框架无关) 标准:WCAG 2.1 AA 级
快速入门(5 分钟)
1. 语义化 HTML 基础
选择合适的元素——不要所有内容都用 div:
<div onclick="submit()">Submit</div>
<div onclick="navigate()">Next page</div>
<button type="submit">Submit</button>
<a href="/next">Next page</a>
为什么这很重要:
- 语义化元素内置键盘支持
- 屏幕阅读器会自动播报角色
- 浏览器会提供默认的无障碍行为
2. 焦点管理
确保交互元素可通过键盘访问:
/* ❌ 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. 文本替代内容
每个非文本元素都需要文本替代内容:
<img src="logo.png">
<button><svg>...</svg></button>
<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。
<button role="button">Click me</button>
<div role="dialog" aria-labelledby="title" aria-modal="true">
<h2 id="title">Confirm action</h2>
</div>
<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:实现键盘导航
所有交互元素都必须可通过键盘访问:
// 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
/* ❌ 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。
步骤 5:确保表单无障碍
每个表单输入项都需要可见标签:
<input type="email" placeholder="Email address">
<label for="email">Email address</label>
<input type="email" id="email" name="email" required aria-required="true">
错误处理:
<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>
用于动态错误的实时区域:
<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)
❌ 使用大于 0 的 tabindex(会打乱自然顺序)
❌ 存在语义化 HTML 时仍添加 ARIA
❌ 关闭对话框后忘记恢复焦点
❌ 在可聚焦元素上使用 role="presentation"
❌ 创建键盘陷阱(无法退出)
预防已知问题
此技能可预防 12 个已记录的无障碍问题:
问题 #1:缺少焦点指示器
错误:交互元素没有可见的焦点指示器 来源:WCAG 2.4.7(焦点可见) 发生原因:CSS 重置移除了默认轮廓 预防方法:始终提供自定义的 focus-visible 样式
问题 #2:颜色对比度不足
错误:文本的对比度低于 4.5:1 来源:WCAG 1.4.3(对比度最低标准) 原因:在白色背景上使用浅灰色文本 预防措施:使用对比度检查工具测试所有文本颜色
问题 #3:缺少替代文本
错误:图像缺少 alt 属性 来源:WCAG 1.1.1(非文本内容) 原因:忘记添加,或以为这项要求是可选的 预防措施:装饰性图像添加 alt="",有意义的图像添加描述性 alt
问题 #4:键盘导航失效
错误:无法通过键盘访问交互元素 来源:WCAG 2.1.1(键盘) 原因:使用带有 onClick 的 div,而不是 button 预防措施:使用语义化交互元素(button、a)
问题 #5:表单输入框没有标签
错误:输入框缺少关联标签
来源:WCAG 3.3.2(标签或说明)
原因:将占位符用作标签
预防措施:始终使用 <label> 元素,并通过 for/id 建立关联
问题 #6:标题级别跳跃
错误:标题层级从 h1 跳到 h3 来源:WCAG 1.3.1(信息与关系) 原因:使用标题进行视觉样式设计,而非表达语义 预防措施:按顺序使用标题,并通过 CSS 设置样式
问题 #7:对话框中没有焦点陷阱
错误:按 Tab 键会从对话框跳转到背景内容 来源:WCAG 2.4.3(焦点顺序) 原因:未实现焦点陷阱 预防措施:为模态对话框实现焦点陷阱
问题 #8:动态内容缺少 aria-live
错误:屏幕阅读器不会播报更新 来源:WCAG 4.1.3(状态消息) 原因:添加动态内容时未进行播报 预防措施:使用 aria-live="polite" 或 "assertive"
问题 #9:仅用颜色传达信息
错误:仅使用颜色传达状态 来源:WCAG 1.4.1(颜色的使用) 原因:用红色文本标记错误,却没有图标或文本说明 预防措施:添加图标和文本标签,而不只是使用颜色
问题 #10:链接文本描述不清
错误:链接文本使用“点击此处”或“阅读更多” 来源:WCAG 2.4.4(链接用途) 原因:通用链接文本缺少上下文 预防措施:使用描述性链接文本或 aria-label
问题 #11:媒体自动播放
错误:视频/音频未经用户控制便自动播放 来源:WCAG 1.4.2(音频控制) 原因:使用 autoplay 属性,但未提供控制项 预防措施:要求用户交互后再开始播放媒体
问题 #12:自定义控件不可访问
错误:自定义选择框/复选框不支持键盘操作 来源:WCAG 4.1.2(名称、角色、值) 原因:使用 div 构建控件,却未添加 ARIA 预防措施:使用原生元素,或完整实现 ARIA 模式
WCAG 2.1 AA 快速检查清单
可感知性
- 所有图像都有替代文本(装饰性图像使用 alt="")
- 文本对比度 ≥ 4.5:1(普通文本),≥ 3:1(大号文本)
- 不单独使用颜色传达信息
- 文本可放大至 200%,且内容不会丢失
- 不自动播放超过 3 秒的音频
可操作性
- 所有功能均可通过键盘访问
- 没有键盘陷阱
- 焦点指示器清晰可见
- 用户可以暂停/停止/隐藏移动内容
- 页面标题能够说明用途
- 焦点顺序合乎逻辑
- 链接用途可从文本或上下文中明确理解
- 有多种方式查找页面(菜单、搜索、站点地图)
- 标题和标签能够说明用途
可理解性
- 已指定页面语言(
<html lang="en">) - 已标记语言变更(
<span lang="es">) - 聚焦或输入时不会意外改变上下文
- 全站导航保持一致
- 提供表单标签/说明
- 已识别并说明输入错误
- 对法律、财务或数据变更提供错误预防机制
稳健性
- HTML 有效(没有解析错误)
- 所有 UI 组件均提供名称、角色和值
- 已识别状态消息(aria-live)
测试工作流
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 选项卡
- 选择“Accessibility”类别
- 生成报告
- 得分达到 90+ 为良好,100 为理想
常见模式
模式 1:无障碍对话框/模态框
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:无障碍标签页
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:跳过链接
<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>
<main id="main-content" tabindex="-1">
</main>
适用场景:所有主内容之前带有导航栏/页眉的多页面网站。
模式 4:带验证的无障碍表单
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>
);
}
适用场景:所有带验证的表单。
使用随附资源
参考文档(references/)
深入了解时可查阅的详细文档:
- wcag-checklist.md - 完整的 WCAG 2.1 A 级和 AA 级要求及示例
- semantic-html.md - 元素选择指南,以及何时使用各类标签
- aria-patterns.md - ARIA 角色、状态、属性及其使用时机
- focus-management.md - 焦点顺序、焦点陷阱、焦点恢复模式
- color-contrast.md - 对比度要求、测试工具、调色板提示
- forms-validation.md - 无障碍表单模式、错误处理、提示
Claude 应在以下情况下加载这些文档:
- 用户要求提供完整的 WCAG 检查清单
- 深入了解特定模式(标签页、手风琴等)
- 颜色对比度问题或调色板设计
- 复杂的表单验证场景
Agent(agents/)
- a11y-auditor.md - 自动化无障碍审计工具,用于检查页面是否存在违规项
适用场景:用户请求对现有页面/组件进行无障碍审计。
高级主题
ARIA 实时区域
三个礼貌级别:
<div aria-live="polite">New messages: 3</div>
<div aria-live="assertive" role="alert">
Error: Form submission failed
</div>
<div aria-live="off">Loading...</div>
最佳实践:
- 对非关键更新(通知、计数器)使用
polite - 对错误和关键警报使用
assertive - 使用
aria-atomic="true",在区域发生变化时朗读整个区域 - 保持提示简洁且有意义
单页应用中的焦点管理
React Router 不会在导航时重置焦点,因此需要自行处理:
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>;
}
无障碍数据表格
<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 移除了轮廓,或对比度不足 解决方案:
*: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="")
- 文本对比度 ≥ 4.5:1(使用 axe 或 Lighthouse 测试)
- 表单输入框都有对应的标签(不能只有占位符)
- 标题层级符合逻辑(没有跳过层级)
- 页面包含
<html lang="en">或适当的语言设置 - 对话框设有焦点陷阱,并在关闭时恢复焦点
- 动态内容使用 aria-live 或 role="alert"
- 不单独依靠颜色传达信息
- 仅使用键盘进行测试(不使用鼠标)
- 使用屏幕阅读器进行测试(NVDA 或 VoiceOver)
- 运行 axe DevTools 扫描(违规项为 0)
- Lighthouse 无障碍评分 ≥ 90
有疑问?遇到问题?
- 查看
references/wcag-checklist.md了解完整要求 - 使用
/a11y-auditorAgent 扫描页面 - 运行 axe DevTools 进行自动化测试
- 使用实体键盘和屏幕阅读器进行测试
标准:WCAG 2.1 AA 级 测试工具:axe DevTools、Lighthouse、NVDA、VoiceOver 成功标准:Lighthouse 评分达到 90 以上,严重违规项为 0