无障碍不是一枚徽章、一个插件,也不是一份满分的自动检测报告。它只回答一件事:用户能不能理解这个页面,并且用他自己的输入输出方式把事情做完。先用原生 HTML,保住键盘路径,再去验证真实结果。
🎙️ 发布并录制于: ·
浏览器早就知道按钮、标题、链接、列表、表格和表单控件各自意味着什么。原生元素把键盘行为、role、state 和各平台的支持一次性打包给你。一个可点击的 div 什么都不带。我的原则说得直接一点:HTML 有元素能干这件事,就用它。只有当 HTML 表达不出那条信息时,才补 ARIA。
<!-- Wrong: no role, no keyboard action, no button semantics -->
<div class="save" onclick="save()">Save</div>
<!-- Right: Enter and Space work without custom key code -->
<button type="button" onclick="save()">Save</button>
<main>
<h1>Account settings</h1>
<nav aria-label="Settings">...</nav>
</main>
role="button",focus、Enter、Space、disabled 行为和状态同步全都还得你自己写。这是用一堆脆弱代码去替代一个原生元素。别用 ARIA 把无障碍树粉刷一遍,却把交互留在坏的状态。把鼠标拔掉。Tab 应该按合理顺序在可交互控件之间移动,Shift 加 Tab 应该往回走。Enter 激活链接和按钮,Space 激活按钮。临时浮层在用户预期它会关的时候,Escape 应该能关掉。方向键属于特定的复合组件内部,不该拿来在整页范围内代替 Tab。
/* Keep DOM order equal to reading and focus order */
<a href="#main" class="skip">Skip to main content</a>
...
<main id="main" tabindex="-1">...</main>
.skip { position: absolute; left: -9999px; }
.skip:focus { left: 1rem; top: 1rem; z-index: 10; }
/* Never do this globally */
*:focus { outline: none; }
别用正数 tabindex。写成 1、2 这种值,等于又造出第二套顺序系统,而它迟早会和 DOM 顺序脱节。用原生可聚焦控件加 DOM 顺序就够了。tabindex="-1" 只在一种情况下用:脚本需要把 focus 移到某个元素上,而这个元素不该出现在正常的 Tab 序列里。
键盘 focus 只回答一个问题:我下一步操作会落在哪。给它一个明显的指示,而且在浅色和深色背景上都要能看清。对话框打开时,把 focus 移进去,Tab 限制在对话框内,Escape 能关,关掉之后把 focus 还给那个打开它的控件。普通内容变化时,别为了「宣布刚才发生了什么」就去挪 focus。
:focus-visible {
outline: 3px solid #0b63ce;
outline-offset: 3px;
}
const opener = document.activeElement;
dialog.showModal();
dialog.querySelector("button, input").focus();
dialog.addEventListener("close", () => opener?.focus());
[aria-hidden="true"] elements contain focusable descendants。第一步:在抽屉或弹层已关闭的状态下按 Tab,把问题复现出来。第二步:找出那个仍然能拿到 focus 的链接、按钮或输入框。第三步:改用原生 hidden 属性、inert,或者干脆把关闭的面板从 DOM 里移除。第四步:重新打开时,主动把 focus 放回去。第五步:正向和反向 Tab 都再测一遍。多加几个 aria-hidden 并不会让元素失去键盘 focus。屏幕阅读器不会朗读你的 CSS 布局。它读的是由 HTML、文本、label 和 ARIA 生成的无障碍树。每个控件都需要一个有用的可访问名称,role 要和它实际做的事对得上,state(展开、勾选、选中、无效)要随界面变化同步更新。标题和地标区域让用户可以跳着走,而不是从头听到尾。
<button aria-expanded="false" aria-controls="filters">
Filters
</button>
<div id="filters" hidden>...</div>
/* On open, change both the DOM and exposed state */
button.setAttribute("aria-expanded", "true");
filters.hidden = false;
/* Icon-only button needs a name */
<button aria-label="Close dialog">×</button>
至少要用一组你的用户真实在用的浏览器加屏幕阅读器组合去测。Windows 上,NVDA 配 Firefox 或 Chrome 是个不错的起点。苹果设备上用 VoiceOver 配 Safari。先学一小段路线:标题、地标、表单控件、链接、按钮。逐句线性听完,不是熟练用户的浏览方式。
placeholder 是提示,不是 label。每个输入框都要连上一个可见的 label。说明文字要放在用得上它之前。提交时给一个简短的错误汇总,每条信息链接到对应字段,把无效字段标出来,并且保留用户已经填的所有内容。错误文案要说清发生了什么,以及怎么改。
<label for="email">Work email</label>
<input id="email" name="email" type="email"
autocomplete="email"
aria-describedby="email-hint email-error">
<p id="email-hint">Use the address for your company account.</p>
<p id="email-error">Enter an address such as [email protected].</p>
/* Set only after validation fails */
input.setAttribute("aria-invalid", "true");
An invalid form control with name='email' is not focusable. 第一步:提交表单,确认那个必填字段是不是躲在隐藏步骤或折叠面板里。第二步:浏览器要做原生校验时,就不能让带 required 的控件一直处于隐藏状态。第三步:在校验前把那一步展开,或者把不参与的控件禁用、从提交里移除。第四步:把 focus 移到第一个无效字段,并把它的错误信息暴露出来。第五步:用键盘再提交一次。别为了让 console 安静就把校验删掉,还不给替代方案。WCAG 用的是对比度数值,不是「这个灰看着还行吧」这种感觉。普通正文一般需要 4.5 比 1,大字号一般需要 3 比 1。重要控件的边界和 focus 指示也需要足够的对比度。要量实际前景色压在实际背景色上的结果,hover、disabled、选中、报错、浅色模式、深色模式都要测。
/* Information survives without color */
.error {
color: #9b1c1c;
border-left: 4px solid currentColor;
}
.error::before { content: "Error: "; font-weight: 700; }
/* Keep links identifiable in body copy */
.prose a { color: #075db7; text-decoration: underline; }
.prose a:hover { text-decoration-thickness: 3px; }
替代文本要完成这张图在当前上下文里承担的任务。带链接的 logo,名字可以按跳转目标来写。图表需要给出结论,并把数据放在附近。纯装饰的图形,alt 写空字符串,让它被跳过。开头不要写「一张图片,内容是……」,屏幕阅读器已经会报出这是图片。
<!-- Informative -->
<img src="checkout-error.png"
alt="Checkout shows Card declined below the card number">
<!-- Decorative -->
<img src="wave.svg" alt="">
<!-- Video: provide accurate captions and a transcript -->
<video controls>
<source src="setup.mp4" type="video/mp4">
<track kind="captions" srclang="en" src="setup-en.vtt"
label="English" default>
</video>
自动生成的字幕只是草稿。人名、专业术语、标点、说话人切换、有意义的声音,都要人工校对。纯音频内容要配文字稿。如果视频里的关键信息靠画面动作传递,可能还需要音频描述,或者一个等效的口述版本。
单页应用不重新加载文档就改了内容,屏幕阅读器可能完全没察觉。对那些必须被播报的简短状态,比如「已保存」「5 条结果」,用 live region。live region 要一开始就存在于 DOM 里,然后只改它的文本。别把整个结果列表塞进一个 assertive 的 live region。
<p id="status" role="status" aria-live="polite"></p>
async function save() {
status.textContent = "Saving";
await saveSettings();
status.textContent = "Settings saved";
}
/* Respect a user's motion preference */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { scroll-behavior: auto; }
.decorative-animation { animation: none; }
}
加载中、成功、失败都是状态,不是视觉装饰。只在真有必要时禁用重复提交,把忙碌状态暴露出来,并且给用户一条重试的路。如果路由切换换掉了主标题,那就同步更新文档 title,并把 focus 放到新页面的起始位置。
WCAG 把要求组织成四条原则:可感知、可操作、可理解、健壮。大多数产品工作,目标是政策或法律要求的那个 WCAG 等级,通常是 A 和 AA。但清单没法告诉你结算流程是否好懂,focus 落点是否有用,替代文本有没有把重点说出来。
Fast test order:
1. Zoom to 200% and check reflow
2. Finish the main task with keyboard only
3. Inspect headings, landmarks, names, roles, and states
4. Run automated checks and fix definite failures
5. Use a screen reader on the main task and error path
6. Test high contrast, dark mode, and reduced motion
7. Include disabled users in research before release
测最小的一条完整用户旅程,而不是一排孤立的组件。要覆盖顺利路径、校验失败、加载状态、空状态,以及从服务端错误里恢复。
Structure
[ ] One descriptive h1; headings do not skip for styling
[ ] Landmarks and page title identify the page
[ ] Native buttons, links, labels, lists, and tables are used
Keyboard and focus
[ ] Main task works with Tab, Shift+Tab, Enter, Space, Escape
[ ] Focus is always visible and never trapped by accident
[ ] Dialog focus enters, stays, and returns correctly
Content and forms
[ ] Controls have useful names; states update programmatically
[ ] Errors identify the field, problem, and repair
[ ] Color is not the only signal; contrast is measured
[ ] Images have contextual alt text; media has accurate captions
Reality check
[ ] 200% zoom and narrow viewport do not hide actions
[ ] Automated findings are reviewed, not treated as a score
[ ] A real screen reader completes the main and error paths
[ ] Known limitations have owners and release decisions
最好的无障碍架构通常都很平淡:原生控件、合理的文档顺序、看得见的 focus、朴素的 label、简短的状态提示。先把这个底子打好。自定义组件和 ARIA 应该是你能解释清楚、也测过的例外,而不是前端开发的默认写法。