关于 building-accessible-interfaces
这项实践型技能指导按 WCAG 2.2 AA 级设计和验证界面。它强调原生语义 HTML、键盘操作、无障碍名称、对比度、表单和动态更新,并要求结合自动化工具、键盘导航与屏幕阅读器进行检查。
主要功能
- 01原生语义 HTML 模式
- 02键盘操作与焦点管理
- 03无障碍名称与对比度检查
- 04无障碍表单和动态播报
- 05自动化、键盘及屏幕阅读器验证
适用场景
- 01构建无障碍网页控件
- 02检查键盘访问和焦点行为
- 03改进表单标签与错误处理
- 04依据 WCAG 2.2 AA 验证界面
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ osmontero/ opencode-skills --skill building-accessible-interfaces安装后在 Claude Code 中输入/building-accessible-interfaces确认已加载。
详细介绍
构建无障碍界面
概述
无障碍不是最后才进行的一项检查。几乎所有无障碍缺陷都源于结构设计决策——本该用 button 却用了 div、只通过颜色传达状态、模态框从未移动焦点——而结构设计决策很难逆转。
核心原则: 原生语义 HTML 默认就具备无障碍性。在它之上添加的每一层,都会成为你需要负责其无障碍性的部分。
目标:WCAG 2.2 AA 级。
铁律
NO CUSTOM CONTROL WHERE A NATIVE ELEMENT EXISTS
<div onclick> 需要角色、tabindex、用于处理 Enter 和 Space 的按键处理程序、焦点样式和禁用状态。而 <button> 无需这些——它本身就具备所有这些特性,在每个平台和辅助技术中都能正确工作,而且是免费的。
写了 <div onclick>?把它换成 <button>。不要添加 ARIA 来让 div 表现得像按钮——ARIA 只会改变辅助技术播报的内容,不会改变浏览器的行为。
检查清单
- 优先考虑语义——正确的元素、地标、标题顺序
- 键盘操作——所有内容均可操作、焦点可见、顺序合理、没有焦点陷阱
- 名称——每个控件和图像都有无障碍名称
- 对比度——文本为 4.5:1,大号文本和 UI 边界为 3:1
- 表单——标签、错误关联、错误播报
- 动态内容——任何无需导航就会变化的内容都使用实时区域
- 动效和目标——尊重减少动态效果的设置,目标尺寸 ≥ 24px(建议 44px)
- 验证——自动扫描,然后进行键盘操作检查,最后使用屏幕阅读器
1. 语义
使用正确的元素
| 需求 | 元素 | 切勿使用 |
|---|---|---|
| 跳转到 URL | <a href> | <button onclick=location…> |
| 执行操作 | <button> | <div onclick>、<a href="#"> |
| 一组选项,只能选择一个 | <input type="radio"> | 样式化的 div |
| 显示/隐藏内容 | <details><summary> | div + JS 切换 |
| 模态对话框 | <dialog> | 使用 position: fixed 的 div |
| 从列表中选择 | <select> | 自定义列表框,除非确有需要 |
| 表格数据 | 带 <th scope> 的 <table> | 由 div 组成的网格 |
| 进度 | <progress> | 动画 div |
<a> 和 <button> 的区别并非外观上的差异:链接支持在新标签页中打开、复制链接,且仅在按 Enter 时激活;按钮在按 Enter 和 Space 时都会激活。屏幕阅读器用户会按元素类型进行导航,因此在链接列表中出现一个“按钮”确实会造成误导。
地标
只能有一个 <main>。使用 <nav> 包裹导航。使用 <header>、<footer>、<aside>。屏幕阅读器用户经常在地标之间跳转;只有嵌套 div 的页面没有任何可供跳转的内容。
<a href="#main" class="skip-link">Skip to content</a>
<header>…</header>
<nav aria-label="Primary">…</nav>
<main id="main" tabindex="-1">…</main>
<footer>…</footer>
多个 <nav> 元素都需要使用能区分它们的 aria-label(“Primary”、“Breadcrumb”、“Pagination”)。
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: var(--space-s); top: var(--space-s); z-index: 9999; }
标题
每个页面使用一个 <h1>。绝不跳过级别(h2 → h4 是缺陷)。标题级别用于传达文档结构——根据结构选择级别,并用 CSS 设置大小。
2. 键盘操作
每个交互元素都必须仅使用键盘即可访问和操作。 这是本文档中最有效的检查方式,只需 90 秒:拔掉鼠标,然后按 Tab 键遍历页面。
| 按键 | 预期行为 |
|---|---|
Tab / Shift+Tab | 按 DOM 顺序在可聚焦元素之间移动 |
Enter | 激活链接或按钮 |
Space | 激活按钮、切换复选框、滚动页面 |
Esc | 关闭模态框、弹出层、菜单;取消当前交互 |
Arrows | 在组合控件中移动——单选按钮组、标签页、菜单、列表框 |
Home / End | 跳到组合控件中的第一项 / 最后一项 |
Roving tabindex——组合控件整体只占用 一个 Tab 停靠点,使用方向键在其中移动:
// Only the active item is tabbable; arrows move the active item.
items.forEach((el, i) => el.tabIndex = i === active ? 0 : -1);
标签页、菜单、工具栏和列表框都采用这种方式。如果标签列表中的每个标签都是单独的 Tab 停靠点,那就是错误的。
焦点可见性
:focus-visible {
outline: 2px solid var(--focus, currentColor);
outline-offset: 2px;
border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; } /* no ring on mouse click */
绝不要在没有替代样式的情况下使用 outline: none。 焦点指示器与相邻颜色之间的对比度必须达到 3:1,厚度至少为 2px(WCAG 2.2 SC 2.4.11/2.4.13)。
焦点管理
上下文变化时,焦点必须随之移动;上下文关闭时,焦点必须返回。
// Opening
lastFocused = document.activeElement;
dialog.showModal(); // <dialog> traps focus natively
dialog.querySelector('h2').focus(); // or the first meaningful control
// Closing
dialog.close();
lastFocused?.focus(); // MUST return focus
切勿让焦点被困在模态框之外。 按 Esc 无法退出的焦点陷阱违反 WCAG 2.1.2,属于 A 级失败——这可能导致键盘用户永久卡在页面上。
SPA 中的路由变化: 将焦点移至新页面的 <h1> 或 <main tabindex="-1">,并播报新标题。否则,屏幕阅读器用户将不知道导航已发生。
切勿在未经提示的情况下移动焦点。 在用户输入过程中自动聚焦某个字段,或在后台更新时跳转焦点,都会让所有人感到困惑。
3. 可访问名称
每个控件都需要一个名称。在开发者工具的「辅助功能」面板中检查计算出的名称,而不是你原本打算写在标记中的名称。
<label for="q">Search</label><input id="q">
<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>
<section aria-labelledby="h-recent"><h2 id="h-recent">Recent</h2>…</section>
规则:
- 装饰性图片:
alt=""。完全省略alt会让屏幕阅读器读出文件名。 - 信息性图片:
alt应描述信息,而非图片本身。图表的替代文本应说明其结论。 - 已命名控件中的图标:使用
aria-hidden="true",避免名称重复。 - 链接文本脱离上下文也必须有意义。页面上出现 8 个「阅读更多」毫无用处;屏幕阅读器用户会浏览链接列表。
aria-label会覆盖可见文本。如果按钮显示「保存」,但设置了aria-label="Submit",语音控制用户说「点击保存」就会失败。两者应保持一致。
4. 对比度
| 内容 | 比率 |
|---|---|
| 正文 | 4.5:1 |
| ≥ 24px 的文本,或 ≥ 18.66px 的粗体文本 | 3:1 |
| 传达含义的图标和 UI 边界 | 3:1 |
| 焦点指示器与相邻颜色 | 3:1 |
请实际测量,不要估算。计算公式见 designing-frontend-interfaces/references/color-and-theme.md,可运行的检查工具见 applying-themes/scripts/check_contrast.py。
颜色绝不能是唯一的信号。 凡是用颜色传达状态的地方,都应添加图标、标签、图案或形状——参见同一参考资料中的表格。
5. 表单
<div class="field">
<label for="email">Email address</label>
<p id="email-hint" class="hint">We'll only use this to send your receipt.</p>
<input id="email" type="email" inputmode="email" autocomplete="email"
aria-describedby="email-hint email-err" aria-invalid="true" required>
<p id="email-err" class="error">Enter an email address with an @.</p>
</div>
| 规则 | 原因 |
|---|---|
每个输入框都有 <label for> | 占位符不是标签——聚焦时会消失,且对比度不足 |
通过 aria-describedby 关联错误信息 | 否则屏幕阅读器无法识别错误信息 |
无效字段设置 aria-invalid="true" | 播报字段状态,而不只是错误信息 |
提示和错误信息都放在 aria-describedby 中 | ID 列表以空格分隔;两者都会被播报 |
切勿用提交按钮上的 disabled 表示验证状态 | Tab 会跳过禁用元素,而且不会说明原因 |
使用 <fieldset><legend> 对相关输入框分组 | 单选按钮组和复选框组需要播报组名 |
使用 required 属性,而不只是红色星号 | 浏览器会原生播报 |
提交失败时:将焦点移至第一个无效字段,或移至一个包含各项错误链接的摘要。
6. 实时区域
任何未通过页面导航发生的变化都必须播报。
<div aria-live="polite" aria-atomic="true" class="sr-only" id="status"></div>
<div role="alert" id="errors"></div>
| 区域 | 用途 | 会打断播报吗? |
|---|---|---|
aria-live="polite" | 状态、结果数量、保存确认 | 否——会等到播报间隙 |
role="alert" / assertive | 错误、会话过期、破坏性警告 | 会 |
关键: 实时区域元素必须在内容变化之前就存在于 DOM 中。注入一个已经包含文本的元素不会触发播报。
// ✅ Region already present; only its text changes
status.textContent = `${n} results`;
// ❌ Injecting a populated live region — silent
container.innerHTML = '<div aria-live="polite">12 results</div>';
请谨慎使用。三个实时区域同时播报会造成难以使用的嘈杂体验。
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0;
margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
注意,如果 .sr-only 内容包含控件,它仍然可以获得焦点——使用 .sr-only:focus-within 样式显示跳过链接。
7. 动效和目标尺寸
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important; animation-iteration-count: 1 !important;
transition-duration: .01ms !important; scroll-behavior: auto !important;
}
}
- 每秒闪烁不得超过 3 次(有癫痫风险,WCAG 2.3.1)。
- 自动播放超过 5 秒的动效需要提供暂停控件(WCAG 2.2.2)。
- 轮播需要提供暂停功能,并且在获得焦点时不得自动切换。
目标尺寸(WCAG 2.2 SC 2.5.8,AA): 最小为 24×24 CSS px。实际建议为 44×44。 不改变视觉尺寸的同时扩大可点击区域:
.icon-btn { position: relative; }
.icon-btn::after { content:""; position:absolute; inset:-10px; }
8. ARIA 的五条规则
- 如果存在语义符合需求的原生元素,就改用原生元素。
- 不要更改原生语义——
<button role="heading">是错误的;应改用包装元素。 - 所有交互式 ARIA 都必须支持键盘操作——带有
role="button"的 div 需要tabindex="0"以及 Enter/Space 处理程序。 - 绝不要在可聚焦元素上使用
role="presentation"或aria-hidden="true"——这样元素仍可聚焦,却不会被读出,这是最糟糕的状态。 - 每个交互元素都需要可访问名称。
**没有 ARIA 好过错误的 ARIA。**错误的 ARIA 会主动传递错误信息;缺少 ARIA 至少还会回退到原生行为。
组件模式
对话框
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete 47 records?</h2>
<p>Records in Q3 Archive will be permanently removed.</p>
<button data-close>Keep records</button>
<button data-confirm class="danger">Delete 47 records</button>
</dialog>
dialog.showModal() 原生提供焦点陷阱、按 Esc 关闭、背景惰性处理以及 ::backdrop 伪元素。你仍需负责:打开时将焦点设到有意义的位置,关闭时恢复焦点。
折叠面板
<button aria-expanded="false" aria-controls="panel-1">Shipping details</button>
<div id="panel-1" hidden>…</div>
同时切换 aria-expanded 和 hidden。或者使用 <details><summary>,完全不写 JavaScript。
标签页
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected="true" aria-controls="p1" id="t1" tabindex="0">Profile</button>
<button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">Billing</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>
使用循环 tabindex;按方向键可在标签页之间移动;按 Home/End 可跳到第一个/最后一个标签页。
Toast
<div aria-live="polite" id="toasts"></div>
错误使用 role="alert"。绝不要自动关闭需要用户处理的内容——WCAG 2.2.1 要求时间限制可调整。
组合框
这是最难实现的常见模式。请使用维护良好的库(React Aria、Radix、Headless UI),不要自行实现。若自行实现:使用带有 aria-expanded、aria-controls 和 aria-activedescendant 的 role="combobox",让它们指向高亮选项的 id——焦点留在输入框中,按方向键时移动的是高亮项。
验证
自动化工具大约能发现 30-40% 的问题。以下三项检查都必须进行。
1. 自动扫描
npx @axe-core/cli http://localhost:5173 --exit
npx lighthouse http://localhost:5173 --only-categories=accessibility --view
也可以在 Playwright 测试中进行——请参阅 testing-webapps 技能:
page.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
violations = page.evaluate("async () => (await axe.run()).violations")
assert not violations, [v["id"] for v in violations]
2. 键盘遍历——不使用鼠标:
- 按 Tab 可以到达每个控件,顺序与视觉布局一致
- 焦点始终可见——不会丢失,也不会在任何背景上变得不可见
- Enter 和 Space 可以触发相应操作
- Esc 可以关闭所有覆盖层
- 在标签页、菜单和单选按钮组中,方向键可以正常工作
- 打开模态框时焦点移入其中;关闭时焦点返回触发元素
- 没有只能通过悬停才能访问的内容
- 模态框之外不存在焦点陷阱
3. 屏幕阅读器——使用实际设备花 5 分钟体验:
| 平台 | 阅读器 | 启动 / 停止 |
|---|---|---|
| macOS | VoiceOver | Cmd+F5 |
| Windows | NVDA(免费) | Ctrl+Alt+N |
| Linux | Orca | Super+Alt+S |
按标题(H)、地标(D)和表单控件(F)导航。每个控件都应读出其名称、角色和状态。
常见错误
**用 <div onclick> 代替 <button>。**这是最常见的问题。它不支持键盘操作,没有角色、没有焦点,也没有禁用状态。
**使用 outline: none,却不提供替代样式。**这会移除键盘用户唯一能用来判断当前位置的提示。
**用占位符代替标签。**聚焦后占位符会消失、对比度不足、会干扰自动填充,也不利于屏幕阅读器识别。
**从模态框关闭后焦点没有返回。**焦点会落到 <body> 上,迫使用户重新按 Tab 遍历整个页面。
**随内容一起插入实时区域。**不会读出任何内容。该元素必须预先存在。
**aria-label 与可见文本相矛盾。**这会破坏语音控制——用户说出屏幕上看到的词,却不会触发任何操作。
**纯图标按钮没有名称。**它会被读作“按钮”。每个图标按钮都需要 aria-label。
**根据字号选择标题级别。**因为看起来合适就选了 <h4>。级别应根据结构确定,字号则用 CSS 设置。
**只在浅色模式下检查对比度。**深色模式需要单独检查。
**在可聚焦元素上使用 aria-hidden="true"。**按 Tab 可以访问到该元素,但它不会被读出。对于隐藏容器,应改用 inert。