Web 無障礙實戰:ARIA 角色與 WCAG 2.2 合規指南
前端工程(更新於 2026年6月17日)
WCAG 2.2 四大原則
WCAG 2.2 圍繞 POUR 四大原則組織合規要求:
| 原則 | 英文 | 核心要求 | 範例 |
|---|---|---|---|
| 可感知 | Perceivable | 資訊可被所有使用者感知 | 圖片 alt、字幕、對比度 |
| 可操作 | Operable | 介面可透過多種方式操作 | 鍵盤導航、足夠時間、無閃爍 |
| 可理解 | Understandable | 內容與操作可被理解 | 一致導航、錯誤提示、輸入幫助 |
| 健壯 | Robust | 可被各類輔助技術解析 | 有效 HTML、ARIA 語義 |
ARIA 角色體系
文件結構角色
<header role="banner">
<nav role="navigation" aria-label="主導航">
<ul role="menubar">
<li role="menuitem"><a href="/home">首頁</a></li>
<li role="menuitem" aria-haspopup="true">
產品
<ul role="menu">
<li role="menuitem"><a href="/saas">SaaS</a></li>
<li role="menuitem"><a href="/api">API</a></li>
</ul>
</li>
</ul>
</nav>
</header>
<main role="main">
<article role="article">
<h2>文章標題</h2>
<p>正文內容</p>
</article>
</main>
<footer role="contentinfo">版權資訊</footer>
視窗小工具角色(Widget Roles)
| 角色 | 用途 | 必需屬性 | 關鍵互動 |
|---|---|---|---|
button |
可點選觸發 | 無 | Enter/Space 啟用 |
checkbox |
選取/未選 | aria-checked |
Space 切換 |
radio |
單選項 | aria-checked |
方向鍵切換 |
slider |
範圍值 | aria-valuenow, aria-valuemin, aria-valuemax |
方向鍵增減 |
tablist / tab |
標籤頁 | aria-selected |
方向鍵切換 |
dialog |
模態/非模態 | aria-modal |
Esc 關閉 |
combobox |
可選輸入 | aria-expanded |
上下選擇/輸入篩選 |
tree / treeitem |
樹形結構 | aria-expanded |
方向鍵展開折疊 |
ARIA 屬性與狀態
核心屬性速查
<!-- 標籤與描述 -->
<button aria-label="關閉對話框">✕</button>
<input aria-labelledby="name-label" aria-describedby="name-hint">
<span id="name-label">使用者名稱</span>
<span id="name-hint">6-20個字元</span>
<!-- 狀態 -->
<div aria-expanded="true" aria-selected="false" aria-checked="mixed">
<div aria-hidden="true" aria-disabled="true" aria-busy="true">
<!-- 關係 -->
<div aria-owns="child-id" aria-controls="panel-id" aria-flowto="next-section">
<!-- 即時區域 -->
<div role="alert" aria-live="assertive">錯誤資訊</div>
<div aria-live="polite" aria-atomic="true" aria-relevant="additions text">狀態更新</div>
第一規則:不要濫用 ARIA
如果原生 HTML 元素已具備所需語義和互動,就不要用 ARIA 重新實作。
<!-- ❌ 反模式 -->
<div role="button" tabindex="0" onclick="submit()">提交</div>
<!-- ✅ 正確做法 -->
<button type="submit">提交</button>
實戰:無障礙模態框
<div class="modal-overlay" id="modalOverlay" hidden>
<div
role="dialog"
aria-modal="true"
aria-labelledby="modalTitle"
aria-describedby="modalDesc"
class="modal"
>
<h2 id="modalTitle">確認刪除</h2>
<p id="modalDesc">此操作不可撤銷,確定要刪除該記錄嗎?</p>
<div class="modal-actions">
<button class="btn-cancel">取消</button>
<button class="btn-confirm">確認刪除</button>
</div>
</div>
</div>
class AccessibleModal {
constructor(dialogEl) {
this.dialog = dialogEl;
this.previousFocus = null;
}
open() {
this.previousFocus = document.activeElement;
this.dialog.hidden = false;
const firstFocusable = this.dialog.querySelector(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
firstFocusable?.focus();
document.addEventListener('keydown', this.handleKeydown);
}
close() {
this.dialog.hidden = true;
this.previousFocus?.focus();
document.removeEventListener('keydown', this.handleKeydown);
}
handleKeydown = (e) => {
if (e.key === 'Escape') this.close();
if (e.key === 'Tab') this.trapFocus(e);
};
trapFocus(e) {
const focusable = [...this.dialog.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
)];
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
}
實戰:無障礙標籤頁
<div class="tabs">
<div role="tablist" aria-label="帳戶設定">
<button
role="tab"
id="tab-profile"
aria-selected="true"
aria-controls="panel-profile"
tabindex="0"
>個人資料</button>
<button
role="tab"
id="tab-security"
aria-selected="false"
aria-controls="panel-security"
tabindex="-1"
>安全設定</button>
<button
role="tab"
id="tab-notify"
aria-selected="false"
aria-controls="panel-notify"
tabindex="-1"
>通知偏好</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile">
<p>個人資料設定內容</p>
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security" hidden>
<p>安全設定內容</p>
</div>
<div role="tabpanel" id="panel-notify" aria-labelledby="tab-notify" hidden>
<p>通知偏好內容</p>
</div>
</div>
document.querySelector('[role="tablist"]').addEventListener('keydown', (e) => {
const tabs = [...e.currentTarget.querySelectorAll('[role="tab"]')];
const idx = tabs.indexOf(document.activeElement);
let nextIdx;
if (e.key === 'ArrowRight') nextIdx = (idx + 1) % tabs.length;
else if (e.key === 'ArrowLeft') nextIdx = (idx - 1 + tabs.length) % tabs.length;
else if (e.key === 'Home') nextIdx = 0;
else if (e.key === 'End') nextIdx = tabs.length - 1;
else return;
e.preventDefault();
tabs.forEach((t) => { t.setAttribute('aria-selected', 'false'); t.tabIndex = -1; });
tabs[nextIdx].setAttribute('aria-selected', 'true');
tabs[nextIdx].tabIndex = 0;
tabs[nextIdx].focus();
});
實戰:無障礙表單
<form aria-label="使用者註冊">
<div class="field">
<label for="email">電子郵件地址 <span aria-hidden="true">*</span></label>
<input
id="email"
type="email"
required
aria-required="true"
autocomplete="email"
aria-invalid="false"
aria-describedby="email-error"
>
<span id="email-error" role="alert" aria-live="assertive"></span>
</div>
<div class="field">
<label for="password">密碼</label>
<input
id="password"
type="password"
minlength="8"
aria-describedby="pwd-rules pwd-strength"
>
<span id="pwd-rules">至少8個字元,包含大小寫字母和數字</span>
<span id="pwd-strength" aria-live="polite"></span>
</div>
<fieldset>
<legend>通知方式</legend>
<label><input type="checkbox" name="notify" value="email"> 郵件</label>
<label><input type="checkbox" name="notify" value="sms"> 簡訊</label>
</fieldset>
<button type="submit">註冊</button>
</form>
自動化偵測工具對比
| 工具 | 型別 | 覆蓋 WCAG | 整合方式 | 特點 |
|---|---|---|---|---|
| axe-core | 執行時函式庫 | A/AA | CI + 瀏覽器 | 最全面,零誤報 |
| Lighthouse | 稽核工具 | A | CLI / DevTools | 綜合效能+無障礙 |
| WAVE | 瀏覽器擴充功能 | A/AA | 手動 | 視覺化標註 |
| NVDA | 螢幕閱讀器 | — | 手動 | 真實使用者體驗 |
| Playwright a11y | E2E 測試 | A | CI | 與測試流程整合 |
import { test } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
test('無障礙檢查', async ({ page }) => {
await page.goto('/register');
const results = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
expect(results.violations).toEqual([]);
});
WCAG 2.2 新增要點
| 新準則 | 等級 | 說明 |
|---|---|---|
| 2.4.11 Focus Appearance | AAA | 焦點指示器面積 ≥ 2px,對比度 ≥ 3:1 |
| 2.4.12 Focus Not Obscured | AAA | 焦點元素不被其他內容遮擋 |
| 3.2.6 Consistent Help | A | 幫助機制位置在頁面間保持一致 |
| 3.3.7 Redundant Entry | A | 同一流程中不要求重複輸入已提供的資訊 |
| 3.3.8 Accessible Authentication | AA | 認證不依賴認知功能測試 |
最佳實踐總結
- 原生元素優先:用
<button>、<input>等原生元素替代 ARIA 重造 - 焦點管理:模態/路由切換後主動聚焦到合理位置
- 即時區域審慎:
aria-live="assertive"僅用於關鍵通知,避免頻繁打斷 - 自動化 + 手動:axe-core 覆蓋約 30-40% 問題,螢幕閱讀器手動測試不可省略
- 持續整合:將無障礙偵測納入 CI 流水線,防止迴歸
本站提供瀏覽器本地工具,免註冊即可試用 →
#无障碍#ARIA#WCAG#屏幕阅读器#可访问性