Accessibility 是一个人能否用他们手上的设备与设置完成同一项任务。Alt text 与 semantic HTML 是 baseline。大多数网站仍然漏掉的是:字段变成 invalid、toast 出现、drawer 移到屏幕外、DOM 与 layout 不一致,或鼠标不可用的时候会发生什么。
这篇 note 依 Kyle 的 walkthrough。用 role 与 name 操作 controls 的 tests 见 Frontend Engineering 中的 Test-Driven Development。Preview gate 的 automated checks 见 React 与 React Native 的 Frontend CI/CD。Native host 上的 VoiceOver 与 TalkBack 属于 如何改善 Mobile Development 的 User Experience。这篇是 web surface。
1. 把错误绑到字段上
可见的邻近不等于 programmatic relationship。输入框下方一条红色消息看起来有关联,但 screen reader 需要 input 去 reference 那条消息。
aria-describedby 把 supporting text 加进字段的 accessible description。可见的 <label> 继续担任 accessible name。用 aria-invalid 暴露 state,并且只在 error 存在时才 reference 它。
"use client"
import { useId, useState } from "react"
export function ProfileNameField() {
const inputId = useId()
const errorId = useId()
const [name, setName] = useState("")
const error =
name.length > 0 && !name.startsWith("Ky")
? "Name must start with Ky."
: undefined
return (
<div>
<label htmlFor={inputId}>First name</label>
<input
id={inputId}
name="givenName"
value={name}
aria-describedby={error ? errorId : undefined}
aria-invalid={error ? true : undefined}
onChange={(event) => setName(event.target.value)}
/>
{error ? <p id={errorId}>{error}</p> : null}
</div>
)
}Error 应该说明发生了什么,以及如何恢复。"Invalid input" 技术上已经 attach 了,仍然没用。如果表单带着多个 invalid fields 提交,把 focus 移到 error summary 或第一个 invalid field;aria-describedby 不会替你决定 focus。
Failure: 在 input 旁边渲染红色文字却没有 relationship,然后假设 screen reader 会推断 layout。
2. 宣告发生了什么变化
Browser 会 announce focus 变化。它不会 announce 每一次 DOM 变化。Validation message、saved status、cart update 或 toast 可以视觉上出现,却对 screen-reader 用户什么都不说。
Live region 让内容 announce,而不抢走 focus:
polite会等到当前 announcement 结束。几乎所有 status update 都应该默认选它。assertive会打断。留给不能等的信息,不要用在常规 validation 或 success。aria-atomic="false"只 announce 变化的 node。true会 announce 整个 region,让 update 保留 context。aria-relevant选择 additions、removals 或 text changes。默认是additions text。
在 update 之前先 mount 空的 region。如果 live region 和第一条 message 出现在同一次 render,有些 browser 与 screen-reader 组合永远观察不到变化。
"use client"
import { useEffect, useRef, useState } from "react"
export function NotificationRegion() {
const regionRef = useRef<HTMLDivElement>(null)
const [message, setMessage] = useState("")
useEffect(() => {
function focusNotifications(event: KeyboardEvent) {
if (event.altKey && event.key.toLowerCase() === "t") {
event.preventDefault()
regionRef.current?.focus()
}
}
window.addEventListener("keydown", focusNotifications)
return () => window.removeEventListener("keydown", focusNotifications)
}, [])
return (
<>
<button type="button" onClick={() => setMessage("Profile saved.")}>
Save profile
</button>
<div
ref={regionRef}
tabIndex={-1}
aria-label="Notifications. Press Alt+T to focus."
aria-live="polite"
aria-atomic="true"
aria-relevant="additions text"
>
{message}
</div>
</>
)
}不要 announce 每一次 keystroke 和每一次 state transition。Chat log、search result count、upload progress 与 form error 的 urgency 不同。从最小有用的 message 开始,用 screen reader 测,再删掉重复的 announcement。
Shortcut 可以让 notification region 在被 announce 之后仍然够得到。把它写进 region 的 name,避免与产品和 assistive-technology shortcuts 冲突,并把 Alt+T 当成 application convention——不是可移植的 browser standard。
Failure: 在 toast container 上放 aria-live="assertive",然后因为 "Added to cart" 和 "Preferences saved" 反复打断用户。
3. 告诉浏览器字段是什么
Label 告诉人字段是什么意思。autocomplete 告诉 browser 它接受哪类 data。这样 password managers、address completion 与 mobile keyboards 才能少打很多字就填完表单。
用描述 value 的标准 token,不要凭 field name 猜。name 表示 full name;given-name 表示 first 或 given name。
export function ContactFields() {
return (
<>
<label htmlFor="given-name">First name</label>
<input id="given-name" name="givenName" autoComplete="given-name" />
<label htmlFor="email">Work email</label>
<input
id="email"
name="email"
type="email"
inputMode="email"
autoComplete="email"
/>
<label htmlFor="password">Current password</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
/>
</>
)
}autocomplete、type 与 inputMode 解决 interaction 的不同部分:
autocomplete标识 data。type给 browser validation 与 control semantics。inputMode提示 on-screen keyboard。
不要为了表单看起来干净就关掉 autocomplete。Authentication 用 current-password、new-password 与 one-time-code,让 browser 与 password managers 帮忙,而不必 parse 页面。
Failure: 每个字段都设 autoComplete="off",然后让人重打 browser 已经知道的 names、addresses 与 credentials。
4. 把隐藏 UI 移出 tree
把 drawer 移到 viewport 外,并不会把它从 keyboard 或 accessibility tree 里藏起来。它的 links 仍在 DOM order 里,所以 focus 看起来会消失,其实是在走过没人看见的 controls。
inert 会把 subtree 从 sequential focus、hit testing、text selection 与 accessibility tree 里移除。关闭的 drawer 应该是 inert。打开时,页面其余部分变成 inert,focus 才不会逃到 overlay 后面。
"use client"
import { useState } from "react"
export function NavigationDrawer() {
const [open, setOpen] = useState(false)
return (
<>
<div inert={open}>
<button
type="button"
aria-expanded={open}
aria-controls="navigation-drawer"
onClick={() => setOpen(true)}
>
Open menu
</button>
<main>{/* Page content */}</main>
</div>
<aside
id="navigation-drawer"
aria-label="Site navigation"
inert={!open}
data-open={open}
>
<button type="button" onClick={() => setOpen(false)}>
Close menu
</button>
<nav>{/* Navigation links */}</nav>
</aside>
</>
)
}CSS 可以把 [data-open="false"] 动画到屏幕外;inert 才是让 visual state 对其他 input methods 诚实的部分。Drawer 关闭时把 focus 还给 opener,打开时把 initial focus 放到有用的 control 上。
Modal 优先用 native <dialog> 并调用 showModal()。Browser 会把它放进 top layer,让 outside document 变成 inert,支持 Escape,并提供 modal semantics。Component 仍然拥有 accessible name、initial focus、close controls 与 focus-return test。
aria-hidden="true" 不能替代 inert。它会把 subtree 从 assistive technology 藏起来,却不能阻止 keyboard focus 进入。
Failure: 把 drawer 平移到 left: -100%,它的 links 却仍然夹在 menu button 与页面第一个 control 的 tab order 之间。
5. 按到真正存在的 control
Control 需要 usable target,也需要 native behavior。Target 至少应该是 24 × 24 CSS pixels。Touch-heavy 的 interface 可以瞄准 44 × 44,让可见 icon 保持小,而包住它的 button 仍然好按。
Action 用真正的 <button>。它已经参与 tab order,响应 Enter 与 Space,暴露 button semantics,并支持 disabled。div 加 role="button" 只改变 assistive technology 怎么称呼它;团队必须重做每一种 interaction。
type CloseButtonProps = {
onClose: () => void
}
export function CloseButton({ onClose }: CloseButtonProps) {
return (
<button
type="button"
className="icon-button"
aria-label="Close navigation"
onClick={onClose}
>
<svg aria-hidden="true" viewBox="0 0 24 24">
<path d="M6 6 18 18M18 6 6 18" />
</svg>
</button>
)
}.icon-button {
display: inline-grid;
min-width: 44px;
min-height: 44px;
place-items: center;
}
.icon-button svg {
width: 20px;
height: 20px;
}
.icon-button:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}Accessible name 应该说明 button 做什么,不是 icon 长什么样。"Close navigation" 在 icon 改版后仍然成立。"X button" 暴露 implementation,还重复 role。
Failure: 给 div 加 role="button" 与 tabIndex={0},只支持 mouse click,却没有 Space、disabled state 或 form behavior。
6. 跳过壳层,直达工作区
重复 page shell 上的第一个 keyboard stop 应该是 bypass header 与 navigation 的 link。没有它,keyboard 或 switch 用户得先 tab 过每个 navigation item,才能到达他们要的页面。
Link 留在 DOM 里,focus 之前 visually hide,并指向页面的 <main>。
export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
<a className="skip-link" href="#main-content">
Skip to main content
</a>
<header>{/* Brand and repeated navigation */}</header>
<main id="main-content" tabIndex={-1}>
{children}
</main>
</>
)
}.skip-link {
position: fixed;
inset-block-start: 1rem;
inset-inline-start: 1rem;
z-index: 100;
transform: translateY(-200%);
}
.skip-link:focus {
transform: translateY(0);
}tabIndex={-1} 让 script 或 browser focus 能到达 <main>,而不把它加进正常 tab sequence。测试真实的 route transition:client-side navigation、sticky headers 与 scroll restoration 都会改变 focus 与 viewport 落点。
Failure: 在 focus 之前用 display: none 藏 skip link。被移出 layout 与 accessibility tree 的元素接不到 reveal 它所需的 focus。
7. 用人读得懂的字体
Typography 是 input constraint,不是 decoration。Readable default 要有 distinct characters、足够 spacing,并在产品实际使用的 size 下保持稳定形状。Atkinson Hyperlegible 与 Lexend 是合理的 default,因为 legibility 就是它们的设计目标。
没有一种 font 对 dyslexia 是 universal 最佳解。受众受益时提供 OpenDyslexic 这类 preference,但不要悄悄替换用户选择,也不要把一种 typeface 说成 cure。
:root {
--font-readable:
"Atkinson Hyperlegible Next", "Atkinson Hyperlegible", system-ui, sans-serif;
}
html {
font-family: var(--font-readable);
line-height: 1.5;
text-size-adjust: 100%;
}
html[data-readable-font="dyslexic"] {
--font-readable: "OpenDyslexic", system-ui, sans-serif;
}
p {
max-inline-size: 70ch;
}Font choice 修不了 12-pixel body copy、low contrast、clipped text、justified rivers,或 200% zoom 就崩掉的 layout。把 type system 当成整体测试:size、line height、line length、weight、fallback、localization 与 reflow。
Failure: 加了 font toggle,fixed-height cards 在用户 zoom 或增大 default font size 时仍然 clip text。
8. 保持 DOM 顺序诚实
Keyboard order 与 screen-reader reading order 跟着 DOM,不跟着 pixels。CSS Grid 与 Flexbox 可以把 item 放到与 source position 矛盾的位置。
先从 single column 也说得通的 logical order 开始。在 relationship 仍然可理解的 wide breakpoint 再做 visual variation。不要用大于 zero 的 tabIndex 去 patch 不诚实的 DOM;那会制造第二套 focus order 让团队维护。
export function FeatureList() {
return (
<div className="feature-list">
<article className="feature">
<div>
<h2>Review changes</h2>
<p>Compare the proposed result before publishing it.</p>
<a href="/review">Review now</a>
</div>
<img src="/review.webp" alt="" />
</article>
<article className="feature feature--flipped">
<div>
<h2>Publish safely</h2>
<p>Release the approved version with a recovery path.</p>
<a href="/publish">Publish now</a>
</div>
<img src="/publish.webp" alt="" />
</article>
</div>
)
}.feature {
display: flex;
flex-direction: column;
}
@media (min-width: 48rem) {
.feature {
flex-direction: row;
}
.feature--flipped {
flex-direction: row-reverse;
}
}Mobile 的两段 article 在 DOM 里都保持 heading、explanation、action,然后才是 decorative image。Wide layout 可以左右交替,但不改变哪个 action 跟着哪个 heading。
Absolute positioning、CSS order 与 *-reverse 不是自动 inaccessible。Defect 是 visual sequence 与 programmatic sequence 表达了不同的 relationship。在每个 layout 会变化的 breakpoint tab 一遍 responsive layout。
Failure: 为了 desktop row 写 image-first markup,在 mobile 再 reverse,让 focus 先跳下去、再跳回来、再跳下去。
9. 测试键盘与工具能看见什么
Accessibility 是 behavior,所以 verification 从操作产品开始——不是读 JSX。
先把 mouse 放下:
- 从 address bar 开始按 Tab。
- 确认 skip link 出现。
- 用预期的 keys 到达并操作每个 control。
- 打开并关闭 overlays;打开时 focus 留在里面,关闭后 focus 回来。
- 提交 invalid 与 valid forms;focus、descriptions 与 announcements 都说得通。
- 在每个 visual order 会变化的 responsive layout 重复一遍。
然后用 browser tooling 暴露容易忘的状态。Chrome 的 Rendering panel 可以 emulate color scheme、forced colors、increased contrast、reduced motion、reduced transparency 与 vision deficiencies。把 text 与 page zoom 到 200%。Content 必须 reflow,不能丢 controls 或 meaning。
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
@media (forced-colors: active) {
.icon-button {
border: 1px solid ButtonText;
}
}跑 Lighthouse 的 accessibility audit,用 color picker 检查 contrast。在 CI 里对 representative rendered states 加 axe。这些工具很快能找到 missing names、invalid ARIA、contrast failures 与 structural mistakes。
Automated score 满分不能证明 task 能完成。Automation 决定不了 focus 是否移到正确位置、announcement 是否有用而不是 noisy、reading order 是否合理,或每个 workflow 能否不用 mouse 完成。在产品支持的 browser 上用 screen reader 跑完 critical journeys。
Failure: Lighthouse 说 100 就 ship,但 closed drawer 仍然 tabbable,save confirmation 也从未 announce。
Takeaway
Accessibility tree、focus order、visual layout 与 dynamic state 应该描述同一个产品。
- 用
aria-describedbyattach supporting errors;用aria-invalid暴露 invalid state。 - 把 dynamic status 放进 persistent 的
aria-live="polite"region;只有等待会造成伤害时才 interrupt。 - 给适用的 fields 正确的
autocomplete、type与inputMode。 - 让 off-screen UI 变成
inert;合适时用 native<dialog>做 modal behavior。 - 用 native controls,target 至少 24 × 24 CSS pixels,touch 上瞄准 44 × 44。
- 让 skip link 成为第一个 keyboard stop,并指向
<main>。 - 选择 legible type,保留 user preferences,并在 200% 验证 reflow。
- 在 wide layout 重排之前,先把 DOM 保持在 logical reading order。
- 把 keyboard 与 screen-reader journeys 和 Rendering tools、Lighthouse、contrast checks、axe 结合起来。
Kyle 的 accessibility checklist 把这次 pass 扩展到 80 多项 checks。把它当 review coverage,不要当成用 assistive technology 完成真实 task 的替代品。