📚 本課學習目標

  • 說明無障礙(accessibility)是什麼、為什麼它是每個網頁的基本要求,以及 WCAG 的四大原則與 A / AA / AAA 等級
  • 先用正確的 HTML 語義解決大多數問題,只在必要時才加 ARIA
  • 寫出鍵盤可以完整操作的頁面:焦點順序、可見的焦點樣式、跳過連結、不可有鍵盤陷阱
  • 替圖片、表單、顏色、動畫、影音做出符合 WCAG 2.2 AA 的決定
  • 用 Lighthouse、axe、鍵盤與螢幕閱讀器(VoiceOver / NVDA)檢查自己的頁面

本課把前面 30 課學過的 HTML、CSS、JavaScript 重新檢視一次:同樣的功能,怎麼寫才能讓用鍵盤、螢幕閱讀器、放大鏡、語音控制的使用者都能使用。

🌟 什麼是無障礙?為什麼重要?

無障礙指的是身心障礙者也能感知、操作、理解網頁。受益的人比想像中多:

情況 需要什麼
全盲、低視能 螢幕閱讀器朗讀、足夠的對比、可放大到 200% 不跑版
色覺異常(男性約 8%) 不能只靠顏色傳達資訊
肢體障礙、暫時受傷(手打石膏) 完全用鍵盤或語音操作
聽障 影片字幕、音訊逐字稿
認知與學習障礙、注意力不足 清楚的結構、一致的操作、可關閉的動畫
所有人在陽光下、單手抱小孩、網路很慢時 以上全部

此外,無障礙在許多地區是法規要求(台灣的「網站無障礙規範 2.0」、歐盟 EN 301 549、美國 ADA / Section 508),而且和 SEO 高度重疊:搜尋引擎「看」網頁的方式和螢幕閱讀器很像。

WCAG:衡量的標準

WCAG(Web Content Accessibility Guidelines)由 W3C 制定,目前版本 2.2(2023)。四大原則縮寫 POUR:

原則 意思 例子
Perceivable 可感知 資訊不能只有一種感官能接收 圖片要有替代文字、影片要有字幕、對比要夠
Operable 可操作 所有功能都能用鍵盤完成、不設時間陷阱 Tab 可以到每個按鈕、沒有閃爍超過 3 次/秒的內容
Understandable 可理解 內容與操作方式可預期 頁面語言正確、錯誤訊息說明怎麼修正
Robust 穩健 各種瀏覽器與輔助科技都能解讀 合法的 HTML、正確的 name / role / value

每條成功準則(success criterion)有等級 A(最低)、AA(法規與業界的一般目標)、AAA(加強)。本課以 AA 為目標。

🏗️ 原則一:先用語義化 HTML,ARIA 是最後手段

第 8 課學過的語義化標籤,就是無障礙的基礎。瀏覽器會把 HTML 轉成無障礙樹(accessibility tree),每個節點有 role(角色)、name(名稱)、state(狀態),螢幕閱讀器讀的是這棵樹。

<!-- ❌ 螢幕閱讀器只聽到「可點擊」甚至什麼都沒有,鍵盤 Tab 也到不了 -->
<div class="btn" onclick="save()">儲存</div>

<!-- ✅ 自帶 role=button、可聚焦、Enter / Space 都能觸發、名稱是「儲存」 -->
<button type="button" onclick="save()">儲存</button>

ARIA 的第一條規則:能用原生 HTML 就不要用 ARIA。 原生元素免費附帶鍵盤行為與狀態;ARIA 只會改變「說法」,不會改變「行為」。

需求 用這個 不要用
按鈕 <button> <div role="button">
連結 <a href> <span onclick>
勾選 <input type="checkbox"> <div role="checkbox">
區塊標題 <h1>–<h6> <div class="title">
導覽、主要內容 <nav>、<main> <div role="navigation">

文件層級的三件事

<!DOCTYPE html>
<html lang="zh-Hant-TW">          <!-- 1. 語言:決定螢幕閱讀器用哪種語音 -->
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">  <!-- 不要加 user-scalable=no -->
  <title>訂單查詢 - 好棒商店</title>  <!-- 2. 每頁唯一、先寫頁面主題 -->
</head>
<body>
  <a class="skip-link" href="#main">跳到主要內容</a>   <!-- 3. 跳過連結 -->
  <header>…</header>
  <nav aria-label="主選單">…</nav>
  <main id="main">
    <h1>訂單查詢</h1>              <!-- 每頁一個 h1,標題不跳級 -->
    …
  </main>
  <footer>…</footer>
</body>
</html>

標題(heading)是螢幕閱讀器使用者最常用的導覽方式(按 H 鍵在標題間跳)。標題層級要反映結構,不是字體大小:想要小一點的 h2,用 CSS 改,不要改成 h4。

🖼️ 原則二:圖片與替代文字

<!-- 資訊性圖片:alt 寫出「這張圖在這裡要傳達什麼」 -->
<img src="chart-q3.png" alt="2024 年第三季營收長條圖:七月 120 萬、八月 150 萬、九月 180 萬,逐月成長">

<!-- 功能性圖片(圖片就是按鈕/連結):alt 寫動作,不寫外觀 -->
<a href="/"><img src="logo.svg" alt="好棒商店首頁"></a>
<button type="button"><img src="search.svg" alt="搜尋"></button>

<!-- 裝飾性圖片:alt="" 讓螢幕閱讀器完全略過(不要省略 alt 屬性!) -->
<img src="divider.png" alt="">

<!-- 複雜圖片:短 alt + 旁邊的長描述 -->
<figure>
  <img src="floorplan.png" alt="三樓平面圖,詳細說明見下方">
  <figcaption>三樓:電梯出口左轉為會議室 A、B,右轉為茶水間與洗手間。</figcaption>
</figure>

判斷流程:這張圖拿掉後,資訊有沒有少? 沒有 → alt="";有 → 把少掉的資訊寫進 alt。不要以「圖片」「照片」開頭(螢幕閱讀器已經會說「圖片」),不要把檔名當 alt。CSS background-image 不會被朗讀,只能放裝飾圖。

⌨️ 原則三:鍵盤操作與焦點

很多使用者完全不用滑鼠。檢查方式很簡單:把滑鼠移開,只用 Tab / Shift+Tab / Enter / Space / 方向鍵操作整個頁面。

焦點順序與 tabindex

  • 原生可互動元素(a[href]、button、input、select、textarea)天生可聚焦,Tab 順序 = DOM 順序。CSS 改變視覺位置(order、flex-direction: row-reverse、絕對定位)不會改變 Tab 順序,所以 DOM 順序就要是合理的閱讀順序。
  • tabindex="0":讓自訂元件加入 Tab 順序(搭配 role 與鍵盤事件)。
  • tabindex="-1":可以用 JavaScript focus() 聚焦,但 Tab 不會停。用於對話框容器、錯誤摘要、跳過連結的目的地。
  • tabindex="1" 以上:不要用。 它會把元素插到所有東西前面,順序馬上亂掉。

焦點一定要看得見(WCAG 2.4.7,A 級)

/* ❌ 最常見的無障礙破壞:為了「好看」拿掉焦點框 */
*:focus { outline: none; }

/* ✅ 只在鍵盤操作時顯示,滑鼠點擊不顯示;對比至少 3:1 */
:focus-visible {
  outline: 3px solid #1a56db;
  outline-offset: 2px;
}

WCAG 2.2 新增 2.4.11 Focus Not Obscured(AA):取得焦點的元素不能被固定的頁首、cookie 橫幅等完全遮住。固定頁首下方的內容記得加 scroll-margin-top。

每頁開頭的第一個可聚焦元素,讓鍵盤使用者不用 Tab 過 40 個選單項目:

.skip-link {
  position: absolute;
  left: 0; top: -100px;          /* 平常藏在畫面外 */
  background: #1a56db; color: #fff; padding: 8px 16px;
}
.skip-link:focus { top: 0; }     /* 取得焦點時出現 */

目的地 <main id="main" tabindex="-1">,否則部分瀏覽器只捲動、不移動焦點。

鍵盤陷阱與對話框

焦點進得去就要出得來(WCAG 2.1.2)。自製對話框(modal)要做到:開啟時把焦點移進去、Tab 在對話框內循環、Esc 關閉、關閉後焦點回到原本的按鈕。最簡單的做法是用原生 <dialog>:

<button type="button" id="open">刪除帳號</button>

<dialog id="confirm" aria-labelledby="confirm-title">
  <h2 id="confirm-title">確定要刪除帳號?</h2>
  <p>此動作無法復原。</p>
  <form method="dialog">
    <button value="cancel">取消</button>
    <button value="ok">確定刪除</button>
  </form>
</dialog>

<script>
  const dialog = document.getElementById('confirm');
  document.getElementById('open').addEventListener('click', () => dialog.showModal());
  // showModal() 會自動:鎖住背景、把焦點移入、支援 Esc、關閉後把焦點還給開啟的按鈕
</script>

自訂元件的鍵盤慣例

如果真的要自己做(例如折疊面板、分頁籤),遵守 WAI-ARIA Authoring Practices 的鍵盤慣例:按鈕用 Enter 與 Space、分頁籤用左右方向鍵、選單用上下方向鍵、Esc 關閉。

<!-- 折疊區塊(disclosure):原生按鈕 + 一個 ARIA 狀態 -->
<button type="button" aria-expanded="false" aria-controls="faq-1">運費怎麼算?</button>
<div id="faq-1" hidden>滿 1,000 元免運,未滿收 80 元。</div>

<script>
  document.querySelectorAll('[aria-expanded]').forEach(btn => {
    btn.addEventListener('click', () => {
      const open = btn.getAttribute('aria-expanded') === 'true';
      btn.setAttribute('aria-expanded', String(!open));
      document.getElementById(btn.getAttribute('aria-controls')).hidden = open;
    });
  });
</script>

螢幕閱讀器會念「運費怎麼算?按鈕,已收合」,按下後變成「已展開」。更簡單的情況直接用原生 <details><summary>,連 JavaScript 都不用。

📝 原則四:表單

第 6、7、26 課的表單,加上這幾件事就符合 AA:

<form novalidate>
  <!-- 1. 每個欄位都有程式上關聯的 label(點 label 會聚焦欄位,就代表關聯成功) -->
  <label for="email">電子郵件(必填)</label>
  <input id="email" name="email" type="email" required autocomplete="email"
         aria-describedby="email-hint email-error" aria-invalid="false">
  <p id="email-hint">我們只用來寄送訂單通知。</p>
  <p id="email-error" class="error" hidden></p>

  <!-- 2. 一組選項用 fieldset + legend,螢幕閱讀器才知道「這些單選鈕是在問什麼」 -->
  <fieldset>
    <legend>取貨方式</legend>
    <label><input type="radio" name="pickup" value="store"> 門市取貨</label>
    <label><input type="radio" name="pickup" value="home"> 宅配到府</label>
  </fieldset>

  <button type="submit">送出訂單</button>
</form>
  • 不要用 placeholder 代替 label:輸入後就消失,對比通常也不夠。
  • 錯誤要用文字說明怎麼修(WCAG 3.3.1、3.3.3),不能只把框線變紅(1.4.1「不能只靠顏色」)。送出失敗時把焦點移到第一個錯誤欄位或錯誤摘要,並用 aria-invalid="true" 與 aria-describedby 讓錯誤訊息被一起朗讀。
  • autocomplete 屬性(WCAG 1.3.5,AA)讓瀏覽器與輔助工具自動填入常見欄位:name、email、tel、postal-code、cc-number……
  • WCAG 2.2 新增 3.3.8 Accessible Authentication(AA):登入不能要求使用者「記住或抄寫」(例如禁止貼上密碼、要求辨識圖片拼圖),要允許密碼管理器與貼上。

錯誤處理範例(搭配第 26 課)

function showError(input, message) {
  const error = document.getElementById(input.getAttribute('aria-describedby').split(' ').pop());
  error.textContent = message;
  error.hidden = false;
  input.setAttribute('aria-invalid', 'true');
}

form.addEventListener('submit', (e) => {
  const email = form.elements.email;
  if (!email.validity.valid) {
    e.preventDefault();
    showError(email, '請輸入有效的電子郵件,例如 name@example.com');
    email.focus();                 // 把使用者帶到問題所在
  }
});

即時訊息:aria-live

JavaScript 動態插入的訊息(「已加入購物車」「搜尋到 12 筆結果」)螢幕閱讀器不會自動唸。放進一個事先就存在的 live region:

<div id="status" role="status" aria-live="polite"></div>   <!-- 一般通知 -->
<div id="alert" role="alert"></div>                         <!-- 緊急錯誤,會打斷朗讀 -->

<script>
  document.getElementById('status').textContent = '已將「無線滑鼠」加入購物車';
</script>

🎨 原則五:顏色、對比、文字

準則 要求(AA)
1.4.3 文字對比 一般文字 4.5:1;大字(≥ 24px,或 ≥ 18.66px 粗體)3:1
1.4.11 非文字對比 圖示、輸入框邊框、焦點框等 UI 元件與相鄰顏色 3:1
1.4.1 不只靠顏色 錯誤、必填、連結、圖表系列都要有第二種提示:文字、圖示、底線、花紋
1.4.4 文字縮放 瀏覽器放大到 200% 內容仍可用;用 rem/em,不要用 px 鎖死字級
1.4.10 重排 320px 寬(或放大 400%)時不出現水平捲軸——第 16 課的響應式設計
1.4.12 文字間距 使用者自行加大行高 1.5、段距 2、字距 0.12em 時內容不被裁切
/* 對比範例(白底) */
color: #767676;   /* 4.54:1 — 剛好通過一般文字 AA */
color: #949494;   /* 3.03:1 — 只勉強通過大字與 UI 元件的 3:1,一般文字不行 */
color: #999999;   /* 2.85:1 — 什麼都不能用(設計稿裡最常見的「淺灰說明文字」) */
color: #1a56db;   /* 6.2:1 — 連結、焦點框都安全 */

對比可以用 Chrome DevTools 的色彩選擇器、Firefox 的無障礙檢查器或 WebAIM Contrast Checker 量。深色模式要各自檢查一次。

🎬 原則六:動畫、動態與影音

  • 尊重使用者設定(WCAG 2.3.3):
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
  • 自動播放超過 5 秒的輪播、影片、跑馬燈要能暫停、停止或隱藏(2.2.2)。
  • 任何東西每秒閃爍不得超過 3 次(2.3.1,A 級)——這條會誘發癲癇,沒有例外。
  • <video> 要有字幕(<track kind="captions">,1.2.2 A 級);只有聲音的內容要有逐字稿;重要的純視覺內容要有口述影像或文字描述(1.2.5 AA)。
  • 不要用 autoplay 播放有聲音的內容;若一定要,提供立即可見的靜音/暫停控制(1.4.2)。

🛠️ 原則七:怎麼測試

沒有任何單一工具能保證無障礙。三層檢查缺一不可:

  1. 自動化工具(抓得到約 30–40% 的問題) - Chrome DevTools → Lighthouse → Accessibility - axe DevTools 瀏覽器擴充功能 - Firefox 開發者工具 → 無障礙環境(可直接看無障礙樹與對比) - HTML 驗證器(validator.w3.org):重複的 id、沒關的標籤都會影響輔助科技
  2. 手動檢查(10 分鐘) - 只用鍵盤走完主要流程:每個可互動元素都到得了?看得到焦點在哪?Esc 能關閉?有沒有卡住? - 把瀏覽器放大到 200%、視窗縮到 320px 寬 - 關掉 CSS(或用 Reader 模式)看內容順序是否合理 - 開啟作業系統的「減少動態效果」設定
  3. 用輔助科技實際操作 - macOS / iOS:VoiceOver(⌘ + F5);Windows:NVDA(免費);Android:TalkBack - 聽一遍:標題是否有意義、圖片替代文字是否合理、表單欄位是否都有名稱、動態訊息有沒有被唸出來

把「鍵盤可操作」「Lighthouse 無障礙 ≥ 95 分」加進第 30 課專題的驗收清單。

💻 實作練習:把一個表單改成無障礙版本

完整範例:examples/lesson-31/example-01-accessible-form.html

範例包含:跳過連結、正確的標題層級與地標、fieldset/legend、aria-describedby 的提示與錯誤訊息、aria-invalid、role="status" 的送出結果、:focus-visible 焦點樣式、prefers-reduced-motion、以及一個用 <button aria-expanded> 做的折疊 FAQ。打開後請:

  1. 全程只用鍵盤填完表單並送出。
  2. 故意留空必填欄位送出,觀察焦點跳到哪裡、螢幕閱讀器唸什麼。
  3. 用 Lighthouse 跑一次,再故意把 label 的 for 拿掉、把 outline: none 加回去,看分數與警告怎麼變。

📝 無障礙檢查清單(AA)

  • [ ] <html lang> 正確、每頁 <title> 唯一且有意義
  • [ ] 每頁一個 h1,標題層級不跳級;main、nav、header、footer 地標齊全
  • [ ] 所有圖片有 alt(裝飾圖 alt="")
  • [ ] 所有功能鍵盤可操作,焦點可見,順序合理,沒有陷阱,有跳過連結
  • [ ] 每個表單欄位有 label;錯誤用文字說明;必填不只靠紅色星號
  • [ ] 文字對比 ≥ 4.5:1、UI 元件 ≥ 3:1;資訊不只靠顏色
  • [ ] 放大 200%、320px 寬皆可用;沒有 user-scalable=no
  • [ ] 動畫尊重 prefers-reduced-motion;自動播放可暫停;沒有每秒閃爍 > 3 次
  • [ ] 影片有字幕;音訊有逐字稿
  • [ ] 動態訊息放在 aria-live 區域;自訂元件有正確的 role / name / state
  • [ ] Lighthouse 無障礙分數 ≥ 95,且實際用鍵盤與螢幕閱讀器走過主要流程

📝 本課重點回顧

  • 無障礙的衡量標準是 WCAG 2.2,四原則 POUR,目標等級 AA。
  • 語義化 HTML 先行;ARIA 只補原生做不到的狀態與名稱,而且不會替你加鍵盤行為。
  • 鍵盤是最重要的測試工具:Tab 到得了、看得到焦點、出得來。
  • 圖片的 alt 取決於「拿掉後少了什麼資訊」;表單的 label、錯誤訊息、autocomplete 缺一不可。
  • 對比 4.5:1 / 3:1、不只靠顏色、尊重減少動態效果、影片要字幕。
  • 自動化工具只能抓三到四成問題,一定要加上手動與輔助科技測試。

🎯 課後練習

  1. 下面這段程式碼有幾個無障礙問題?逐一指出並改寫。
    <div class="card" onclick="location.href='/sale'">
      <img src="sale.jpg">
      <span style="color:red">限時特賣</span>
    </div>
  2. 一個「加入購物車」按鈕按下後,頁面右上角出現 2 秒的「已加入」提示後消失。螢幕閱讀器使用者會錯過它。要怎麼修?
  3. 設計師給的次要文字顏色是 #999999(白底)。對比約 2.85:1。哪些地方可以用、哪些不行?你會怎麼跟設計師溝通?
  4. 自製一個分頁籤(tabs)元件時,需要哪些 role、哪些 ARIA 屬性、哪些鍵盤操作?(提示:查 WAI-ARIA Authoring Practices 的 Tabs pattern。)
  5. 為什麼「tabindex="1"、tabindex="2" 把重要按鈕排在前面」是壞主意?正確的做法是什麼?

參考答案

  1. 至少五個問題:(a) div 不可聚焦、不是按鈕也不是連結——改成 <a href="/sale">;(b) 圖片沒有 alt——若是資訊圖寫 alt="限時特賣:全館 8 折",若純裝飾寫 alt="";(c) 「限時特賣」只靠紅色強調——加上文字或圖示,並確認紅色與白底對比 ≥ 4.5:1;(d) onclick 沒有鍵盤對應——改用原生 <a> 後自然解決;(e) 行內樣式與整張卡片可點擊但沒有可聚焦的目標,螢幕閱讀器無法得知它可以操作。改寫:
    <a class="card" href="/sale">
      <img src="sale.jpg" alt="">
      <span class="badge">🔥 限時特賣</span>
    </a>
  2. 把提示放進頁面載入時就存在的 <div role="status" aria-live="polite">,用 JavaScript 更新其 textContent;視覺上可以 2 秒後淡出,但 DOM 文字可保留久一點(或至少讓 live region 有時間朗讀)。若是錯誤改用 role="alert"。
  3. 2.85:1 連大字的 3:1 都不到,所以正文、說明文字、表單提示都不行;只能用在純裝飾(例如分隔線)或停用狀態的元件(1.4.3 的例外)。溝通方式:拿 WebAIM Contrast Checker 的數字給設計師看,並提出最接近的合格色(白底上 #767676 = 4.54:1),說明這是 AA 法規要求而非個人喜好。
  4. 容器 role="tablist";每個籤 role="tab"、aria-selected="true|false"、aria-controls="面板 id",只有選中的籤 tabindex="0"、其餘 -1;面板 role="tabpanel"、aria-labelledby="籤 id"。鍵盤:Tab 進入選中的籤,左右方向鍵在籤之間移動(並自動或按 Enter/Space 啟用),Home/End 跳到第一/最後一個,Tab 再按一次進入面板內容。
  5. 正數 tabindex 會讓這些元素排在所有沒有設定的元素之前(包含跳過連結與導覽),而且日後任何人新增元素都得維護這串數字,順序很快就和視覺不一致。正確做法是調整 DOM 順序讓重要元素本來就在前面,必要時用 CSS 排版;自訂元件只用 tabindex="0" 與 -1。

🔗 延伸閱讀


← 上一課 | 返回目錄