承昨日談鍵盤操作與焦點管理,把「怎麼移動」講清楚了;今天往無障礙更核心的一環走:螢幕報讀器(screen reader)聽到的到底是什麼,以及語意化 HTML 與 ARIA 的正確用法。剛出爐的 WebAIM Million 2026 報告丟出一個刺眼的數字:首頁「有用 ARIA」的平均錯誤數是 59.1,「沒用 ARIA」的反而只有 42。ARIA 不是拿來灑的裝飾,用錯比不用還糟——這正是無障礙最常被做壞的地方,今天就把它拆開。

📖 學

先建立正確的心智模型:螢幕報讀器不看畫面,它讀的是瀏覽器根據 HTML 建構出來的「無障礙樹(accessibility tree)」。每個節點對報讀器來說只有三件事有意義——角色(role)、名稱(name)、狀態(state)。一顆按鈕的角色是 button、名稱是「送出訂單」、狀態是「已停用」,報讀器就會念出「送出訂單,按鈕,停用」。你在 CSS 裡下的圓角、陰影、hover 動畫,它一律看不見。無障礙的本質,就是確保這棵樹上的每個互動元件都有正確的角色、清楚的名稱、即時的狀態。

第一條、也是最重要的一條規則,叫「No ARIA is better than bad ARIA」(WAI-ARIA 官方第一準則的白話版):能用原生 HTML 元素表達的語意,就不要用 ARIA 去模擬。<button> 天生就有 button 角色、可被 Tab 聚焦、可用 Enter / Space 觸發、可被 disabled 停用——這一切免費附贈。但如果你用 <div onclick="..."> 做按鈕,報讀器只會念到一團無意義的文字,鍵盤也到不了。這時很多人會補上 role="button",以為解決了——沒有。role 只是貼了張「我是按鈕」的標籤,它不會自動補上鍵盤可聚焦(要自己加 tabindex="0")、不會自動綁 Enter / Space 的觸發、不會自動處理 disabled。你等於是手工重造 <button> 的所有內建行為,而且十之八九漏做。WebAIM 那個「有 ARIA 錯更多」的統計,大半就是這樣來的:開發者貼了角色,卻沒補齊角色該有的鍵盤與狀態,反而給報讀器一個「說謊的標籤」。

第二個常做壞的是可及名稱(accessible name)。一顆只有圖示的按鈕——垃圾桶、放大鏡、漢堡選單——在報讀器耳裡可能是「按鈕」兩個字,完全不知道按下去會發生什麼。補救有兩條路,而且優先順序很清楚:若畫面上已經有可見文字,用 aria-labelledby 指向那段文字,讓可及名稱與畫面同步;若真的沒有可見文字(純圖示),才用 aria-label 直接寫。順帶提醒,裝飾性圖示要用 aria-hidden="true" 或空的 alt="" 藏起來,別讓報讀器把一個 SVG 檔名念出來。名稱的原則是:報讀器聽到的,要和明眼人看到的意義一致。

第三,也是 SPA 時代最容易漏的,是動態內容的通知:aria-live。畫面上悄悄冒出來的東西——表單送出後的「儲存成功」、搜尋框下方即時更新的結果數、購物車數量變化、非同步載入的錯誤訊息——明眼人一眼看到,報讀器使用者卻完全不知道。因為焦點沒有移過去,報讀器不會主動去念一個它沒被引導到的區域。解法是預先在 DOM 裡放一個 live region,標上 aria-live 屬性,之後往裡面塞文字,報讀器就會自動播報。用 aria-live="polite"(等於 role="status")處理一般成功/狀態訊息,它會等使用者當下的朗讀告一段落再念;用 aria-live="assertive"(等於 role="alert")處理必須立刻打斷的錯誤或警告。這裡有兩個實務眉角:一是 live region 的容器必須在頁面載入時就存在、且一開始是空的,你只更新它的內容而不是整塊重建,否則有些報讀器不會觸發;二是別把整頁包成 assertive,那會變成永無止境的打斷,反而讓人無法使用。

把這三層疊起來,無障礙的優先順序其實很簡單:先用對的 HTML 元素(免費拿到角色、鍵盤、狀態)→ 補上清楚的可及名稱 → 動態變化用 live region 通知。ARIA 的定位是「原生語意不夠時的補丁」,不是預設工具。而且要記住:ARIA 只改變無障礙樹上的語意標籤,它從來不會幫你加任何鍵盤功能或視覺效果——所有互動行為,你都得自己用 JavaScript 補齊。這也是為什麼真正該做的檢查,不是跑一次自動掃描就算數,而是打開 VoiceOver 或 NVDA,閉上眼睛用耳朵走一遍你的頁面。

🧠 記

  • 螢幕報讀器只認三件事:角色、名稱、狀態;它讀無障礙樹,看不見你的 CSS。
  • 第一準則:No ARIA is better than bad ARIA。原生 <button> / <a> / <input> 能表達的語意,絕不用 <div> + role 重造。
  • role 只貼標籤,不附鍵盤與狀態;用了 role="button" 就得自己補 tabindex="0"Enter/Space 觸發與 disabled 處理。
  • 可及名稱:有可見文字用 aria-labelledby,純圖示才用 aria-label;裝飾圖示用 aria-hidden="true" 藏起來。
  • 動態內容用 aria-live:一般狀態 polite(role="status")、必須打斷的錯誤 assertive(role="alert");容器要預先存在且初始為空。

✍️ 實踐

拿你手上任一個含互動元件的頁面,做一次三步體檢。第一步,全域搜尋 role=<div onClick / <span onClick:每找到一個用 div/span 假扮的按鈕,就改回 <button type="button">,把手工綁的鍵盤事件與 tabindex 一併刪掉,讓原生元素接手。第二步,找出所有純圖示按鈕(垃圾桶、搜尋、選單、關閉),逐一補上可及名稱——畫面旁有文字就 aria-labelledby,沒有就 aria-label="關閉對話框",並把裝飾 SVG 加上 aria-hidden="true"。第三步,找一處會非同步變化的區域(表單送出提示或搜尋結果數),在版面裡預埋一個 <div role="status" aria-live="polite"> 空容器,把成功訊息改成更新這個容器的文字。最後打開系統內建報讀器(Mac 按 Cmd+F5 開 VoiceOver),閉眼用 Tab 與方向鍵走完整個流程,凡是「聽起來不知道這是什麼、按下去會怎樣」的地方,就是還沒做完的。

🔗 延伸學習

💬 問 AI

我在做一個含互動元件的前端頁面,想確認無障礙(a11y)做對了。請幫我:
1. 用「角色 / 名稱 / 狀態」的無障礙樹模型,說明螢幕報讀器會怎麼解讀我這段 UI;
2. 檢查我哪裡用了 <div>/<span> 假扮按鈕或連結,該怎麼改回原生 HTML 元素;
3. 逐一指出純圖示按鈕的可及名稱該用 aria-labelledby 還是 aria-label,並示範寫法;
4. 找出會非同步變化的區域,建議該用 aria-live="polite" 還是 "assertive",並示範 live region 的正確寫法。
以下是我的元件程式碼:(貼上你的 HTML / JSX)