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 的替代品。