在日常專案開發中,當表單欄位除了主標題(
<label>)之外,還需要附帶「密碼格式規則說明」、「字數上限提示」與「即時驗證錯誤紅字」時,必須仰賴aria-describedby建立程式化關聯。然而最近在審查多個前端專案的無障礙(Accessibility, A11y)實作時,我們發現許多工程師把aria-describedby="pwd-hint pwd-error"預先寫死在<input>上,並將寫好錯誤文字的#pwd-error設為display: none——結果根據 W3C Accessible Name and Description Computation 1.2 (AccName 1.2) 規範,螢幕閱讀器竟在使用者剛聚焦、一個字都還沒打的時候,就把隱藏在display: none裡的錯誤訊息大聲唸了出來!這篇筆記將帶大家從 W3C AccName 1.2 底層計算規則出發,徹底搞懂label、aria-labelledby與aria-describedby的正確分工與動態綁定實作。
⚡ 快速簡單使用(30 秒複製即用範本)
以下提供符合 W3C AccName 1.2 與 WCAG 2.2 SC 3.3.1 (Error Identification) 標準的動態 aria-describedby 綁定範本(避免預先綁定隱藏錯誤節點導致誤報,並在驗證失敗時將錯誤 ID 排在第一位優先播報):
<div class="form-field">
<label for="api-token">API 存取金鑰</label>
<!-- 初始狀態:僅綁定長駐說明 #token-hint,不綁定錯誤容器 -->
<input
type="password"
id="api-token"
name="apiToken"
aria-describedby="token-hint"
aria-invalid="false"
required
>
<!-- 長駐格式說明 -->
<p id="token-hint" class="field-hint">
金鑰須以 sk_live_ 開頭,總長度至少 24 個字元。
</p>
<!-- 動態錯誤容器:初始為空且加上 hidden,驗證失敗時才填入文字並掛入 aria-describedby -->
<p id="token-error" class="field-error" hidden></p>
</div>
<script>
const tokenInput = document.getElementById('api-token');
const tokenError = document.getElementById('token-error');
function validateApiToken(inputEl) {
const val = inputEl.value.trim();
const isValid = val.startsWith('sk_live_') && val.length >= 24;
if (!isValid) {
tokenError.textContent = '錯誤:金鑰格式不符,必須以 sk_live_ 開頭且滿 24 碼。';
tokenError.hidden = false;
inputEl.setAttribute('aria-invalid', 'true');
// 關鍵:將 token-error 排在 token-hint 前面,讓螢幕閱讀器優先唸出錯誤原因!
inputEl.setAttribute('aria-describedby', 'token-error token-hint');
} else {
tokenError.textContent = '';
tokenError.hidden = true;
inputEl.setAttribute('aria-invalid', 'false');
inputEl.setAttribute('aria-describedby', 'token-hint');
}
}
tokenInput.addEventListener('blur', (e) => validateApiToken(e.target));
</script>
一、核心速查:表單無障礙命名(Name)與描述(Description)屬性對照表
在無障礙樹(Accessibility Tree)中,每一個表單控制項都有兩個核心文字屬性:Accessible Name(無障礙名稱:回答「這個欄位是什麼?」) 與 Accessible Description(無障礙描述:回答「這個欄位有什麼填寫限制或錯誤?」)。下表整理了各屬性的計算優先權與職責邊界:
| 屬性 / 標籤 | 歸屬維度與 W3C AccName 1.2 優先權 | 螢幕閱讀器播報行為與特性 | 典型適用場景 |
|---|---|---|---|
aria-labelledby="id1 id2" | 無障礙名稱 (Name) (第 1 優先,最高) | 直接覆蓋該元素上的 aria-label 與原生 <label for> 文字;支援串接多個元素 ID | 表格矩陣內的輸入框(同時組合「列標題 ID + 欄標題 ID」作為名稱) |
aria-label="純文字" | 無障礙名稱 (Name) (第 2 優先) | 直接覆蓋原生 <label for> 文字;畫面上不可見 | 無文字標籤之純圖示按鈕(若已有可見 <label> 切勿濫用,名稱一旦不包含可見文字就會違反 WCAG 2.5.3) |
<label for="id"> | 無障礙名稱 (Name) (第 3 優先,最推薦) | 作為標準欄位名稱播報,且提供滑鼠點擊文字自動聚焦 <input> 的原生互動 | 一般的表單輸入框、核取方塊與下拉選單 |
aria-describedby="id1 id2" | 無障礙描述 (Description) (描述維度主屬性) | 依屬性內 ID 順序串接成一段描述文字,在名稱之外另行提供給輔助技術(實際播報時機依螢幕閱讀器而定) | 格式規則提示、字數限制、動態驗證錯誤訊息 |
aria-errormessage="id" | 錯誤訊息關聯 (須搭配 aria-invalid="true") | WAI-ARIA 1.2 規定 aria-invalid 為 false 時,使用者代理不得公開這個關聯;欄位無效時,被參照的錯誤元素必須保持可見 | WAI-ARIA 1.2 錯誤訊息語意化標記(實務上常與 aria-describedby 併用或擇一) |
圖:W3C AccName 1.2 表單欄位無障礙名稱(Accessible Name)三級優先權鏈與無障礙描述(aria-describedby)計算架構圖
二、深度拆解:aria-describedby 的三大底層規範與常見誤區
1. 為什麼 aria-describedby 指向 display: none 的錯誤元素會導致提前誤報?
在許多初學者的教學文章中,常會看到以下寫法:
<!-- ❌ 常見錯誤反模式:預先寫死 pwd-error 且內含錯誤文字 -->
<input type="password" id="pwd" aria-describedby="pwd-hint pwd-error">
<p id="pwd-hint">密碼至少 8 碼。</p>
<p id="pwd-error" style="display: none;">錯誤:密碼長度不足!</p>
寫這段程式碼的工程師以為:「既然 #pwd-error 設了 display: none,螢幕閱讀器應該就不會唸它吧?」
事實正好相反!根據 W3C AccName 1.2 §4.3.2 Computation steps (Step 2A) 的明確規範:
當一個節點是透過
aria-labelledby或aria-describedby直接參照(Directly Referenced) 時,即使該節點設定了display: none、visibility: hidden或 HTMLhidden屬性,無障礙名稱與描述計算演算法依然會強制提取該隱藏節點內的文字!
AccName 1.2 在同一節的附註說明:輔助技術預設不會轉述隱藏資訊,但作者可以透過 aria-labelledby 或 aria-describedby 明確把隱藏文字納入名稱或描述,因此這個機制可以用來提供「不在畫面上顯示的補充說明」;但若套用在預先寫好錯誤文字的 #pwd-error 上,就會導致視障使用者第一次按 Tab 進入密碼框時,馬上被誤報「錯誤:密碼長度不足!」。
圖:aria-describedby 預先指向 display: none 錯誤節點之誤報問題 vs. 標準動態綁定流程
兩種常見的解決思路:
- 動態清空文字法:在驗證通過或初始狀態下,確保
#pwd-error的文字內容為空字串(textContent = ''),僅在驗證失敗時才寫入錯誤文字。 - 動態切換
aria-describedbyID 清單法(如開頭 30 秒範本):初始僅設定aria-describedby="pwd-hint";當驗證失敗時,動態更新為aria-describedby="pwd-error pwd-hint"。
2. 多重 ID 串接順序(IDREFS Order):把「錯誤訊息 ID」排在「提示說明 ID」前面!
aria-describedby 支援以空白分隔的多個 ID(例如 aria-describedby="id-a id-b")。
請記住:AccName 會依 ID 在 aria-describedby 屬性字串中出現的先後順序串接描述文字,而不是依它們在 HTML DOM 樹中的上下位置! 例如 DOM 中 #g1 在前、#g2 在後,aria-describedby="g2 g1" 算出的描述會是「第二段 第一段」。
當使用者填錯表單回到該欄位修正時,他最迫切想知道的是「哪裡填錯了」,而不是再聽一次冗長的填表說明。因此在動態掛載錯誤 ID 時,將 error-id 放在第一個位置(aria-describedby="error-id hint-id"),能讓視障使用者先聽到錯誤原因,不必等說明唸完。
3. 攤平為純文字字串(Flattened String):切勿在被參照元素內塞入唯一超連結
當瀏覽器計算 aria-describedby 的內容時,會將目標元素內的 DOM 子樹全部攤平(Flatten)為一段純文字字串。
如果你在 <p id="terms-hint">請參閱 <a href="/policy">隱私權政策</a></p> 中放了一個超連結,並用 <input aria-describedby="terms-hint"> 指向它:
- 當焦點停留在
<input>上時,螢幕閱讀器只會把「請參閱 隱私權政策」當成純文字唸過去,使用者無法在停留在<input>的狀態下直接觸發該連結(必須再按Tab鍵將焦點移出<input>到達<a>上才能點擊)。因此若說明內含互動連結,請確保該元素在視覺與 DOM 樹中保持可見且可被Tab獨立聚焦。
三、現代前端框架實務:使用 React useId() 打造零 ID 衝突之無障礙表單元件
在 React 或 Vue 等元件化架構中,如果將 <InputField> 元件內的 id="email-hint" 寫死,當同一個頁面渲染兩個 <InputField> 時就會發生 HTML id 重複衝突,導致 label for 與 aria-describedby 指錯對象。
利用 React 18 起原生提供的 useId() Hook,我們可以為每個元件實例自動產生唯一 ID 前綴,只要伺服器與用戶端渲染的元件樹相同,SSR(伺服器端渲染)輸出的 ID 與 Hydration 時就會一致,並自動組裝 aria-describedby:
import { useId } from 'react';
export function AccessibleTextField({
label,
hintText,
errorText,
required = false,
...inputProps
}) {
// 產生 SSR 安全的唯一基礎 ID(實際字串格式由 React 決定,不要依賴其內容)
const baseId = useId();
const inputId = `${baseId}-input`;
const hintId = `${baseId}-hint`;
const errorId = `${baseId}-error`;
// 動態組合 aria-describedby:有錯誤時優先播報 errorId,再播報 hintId
const describedByIds = [
errorText ? errorId : null,
hintText ? hintId : null,
]
.filter(Boolean)
.join(' ');
return (
<div className="form-field">
<label htmlFor={inputId}>
{label}
{required && <span aria-hidden="true"> *</span>}
</label>
<input
id={inputId}
required={required}
aria-invalid={Boolean(errorText)}
aria-describedby={describedByIds || undefined}
{...inputProps}
/>
{hintText && (
<p id={hintId} className="field-hint">
{hintText}
</p>
)}
{errorText && (
<p id={errorId} className="field-error" role="alert">
{errorText}
</p>
)}
</div>
);
}
四、常見錯誤與踩坑排查 (Pitfalls & Troubleshooting)
- 在已有
<label for>的輸入框上又畫蛇添足加上簡短的aria-label:- 現象:HTML 寫了
<label for="tax-id">統一編號(選填,共 8 碼數字)</label>,工程師又在<input id="tax-id" aria-label="統編">上加了aria-label。 - 後果:因為
aria-label的優先權高於<label for>,螢幕閱讀器只會唸出"統編",把原本<label>裡的「選填,共 8 碼數字」完全覆蓋吞掉!同時因為無障礙名稱"統編"不包含可見文字"統一編號",還會直接違反 WCAG 2.2 SC 2.5.3 (Label in Name),語音輸入使用者照著畫面說出「統一編號」時,可能無法對應到這個欄位。 - 解法:只要有原生
<label for>,就絕對不要在<input>上重複設定aria-label。
- 現象:HTML 寫了
aria-describedby指向不存在或拼錯的id(Broken ARIA Reference):- 當條件渲染(Conditional Rendering)把
<p id="hint">從 DOM 移除,卻忘了把<input aria-describedby="hint">上的屬性一併移除時,自動化無障礙檢測工具(如 Deque axe-core)會在aria-valid-attr-value規則中把這個懸空參照列為需人工確認(needs review)的項目。請務必如上方 React 範例所示,當沒有對應元素時傳入undefined移除該屬性。
- 當條件渲染(Conditional Rendering)把
五、總結與官方規範參考文獻
掌握 <label> 與 aria-describedby 的無障礙名稱與描述計算機制,是打造企業級高合規表單的核心關鍵:
- 名稱(Name)與描述(Description)職責分離:用原生
<label for>定義欄位身分,用aria-describedby綁定格式規則與錯誤訊息。 - 防範隱藏錯誤節點提前誤報:切勿讓
aria-describedby常駐指向內含錯誤文字但設為display: none的元素;應採用條件渲染或動態切換 ID 清單。 - 錯誤訊息優先播報與唯一 ID 管理:串接多個 ID 時將
errorId排在hintId之前,並在元件化開發中善用useId()杜絕 ID 重複。
官方權威參考文獻
- W3C Accessible Name and Description Computation 1.2 (AccName 1.2)
- W3C HTML Accessibility API Mappings 1.0 (HTML-AAM)
- W3C WAI-ARIA 1.2(aria-describedby、aria-errormessage、aria-invalid)
- W3C WAI Tutorials — Forms: User Notifications & Error Messages
- W3C WCAG 2.2 Understanding SC 3.3.1 — Error Identification
- W3C WCAG 2.2 Understanding SC 3.3.2 — Labels or Instructions
- MDN Web Docs —
aria-describedbyattribute - React Official Documentation —
useIdHook for accessibility attributes


發佈留言