前端無障礙模式 是做什麼的?
React 和 Next.js 的實用無障礙模式。涵蓋程式碼審查中最常被標記的問題:遺失的表單標籤、錯誤的 ARIA 使用、非語意的互動元素以及損壞的鍵盤導航。
何時啟用
- 建置或審查表單元件(
<input>、<select>、<textarea>) - 建立互動式元素(模態視窗、下拉選單、工具提示、分頁標籤)
- 使用帶有
onClick的<div>或<span> - 在任何元素上添加
aria-*屬性 - 實作鍵盤導航或焦點管理
- 收到來自程式碼審查工具(CodeRabbit、ESLint a11y)的無障礙反饋
- 建置必須支援螢幕閱讀器的元件
表單無障礙性
遺失的 htmlFor / id 配對以及未連結的錯誤訊息是程式碼審查中最常見的問題。
標籤連接
// 不好:標籤與輸入框沒有關聯 — 螢幕閱讀器無法將它們配對
<label>Email</label>
<input type="email" />
// 好:htmlFor 與 input id 匹配
<label htmlFor="email">Email</label>
<input id="email" type="email" />
必填欄位
// 不好:僅視覺上的星號無法向螢幕閱讀器傳達任何資訊
<label htmlFor="email">Email *</label>
<input id="email" type="email" />
// 好:required 啟用瀏覽器原生驗證;aria-required 向螢幕閱讀器發出信號
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
錯誤訊息
// 不好:錯誤文字在視覺上存在,但沒有連結到輸入框
<input id="email" type="email" />
<span className="error">無效的電子郵件地址</span>
// 好:aria-describedby 將輸入框連接到其錯誤訊息
// aria-invalid 向螢幕閱讀器指示無效狀態
<input
id="email"
type="email"
aria-describedby="email-error"
aria-invalid={!!error}
/>
{error && (
<span id="email-error" role="alert">
{error}
</span>
)}
完整的可無障礙表單
interface LoginFormProps {
onSubmit: (email: string, password: string) => void;
}
export function LoginForm({ onSubmit }: LoginFormProps) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [errors, setErrors] = useState<{ email?: string; password?: string }>({});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: typeof errors = {};
if (!email) newErrors.email = '需要填寫電子郵件';
if (!password) newErrors.password = '需要填寫密碼';
if (Object.keys(newErrors).length) {
setErrors(newErrors);
return;
}
onSubmit(email, password);
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="email">
電子郵件 <span aria-hidden="true">*</span>
</label>
<input
id="email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
aria-required="true"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
autoComplete="email"
/>
{errors.email && (
<span id="email-error" role="alert">
{errors.email}
</span>
)}
</div>
<div>
<label htmlFor="password">
密碼 <span aria-hidden="true">*</span>
</label>
<input
id="password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
aria-required="true"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
autoComplete="current-password"
/>
{errors.password && (
<span id="password-error" role="alert">
{errors.password}
</span>
)}
</div>
<button type="submit">登入</button>
</form>
);
}
語意化 HTML
使用符合意圖的元素。螢幕閱讀器和鍵盤使用者依賴原生語意。
// 不好:div 沒有角色、沒有鍵盤支援、沒有無障礙名稱
<div onClick={handleClick}>提交</div>
// 好:button 可獲得焦點,可透過 Enter/Space 啟動,宣告為「按鈕」
<button type="button" onClick={handleClick}>提交</button>
// 不好:非語意的導航
<div onClick={() => navigate('/home')}>首頁</div>
// 好:anchor 支援右鍵、中鍵點擊和鍵盤導航
<a href="/home">首頁</a>
// 不好:標題層級被跳過(h1 到 h4)
<h1>儀表板</h1>
<h4>近期活動</h4>
// 好:連續的標題層級
<h1>儀表板</h1>
<h2>近期活動</h2>
ARIA 屬性
僅在原生 HTML 語意不足時使用 ARIA。錯誤的 ARIA 比沒有 ARIA 更糟。
aria-label vs aria-labelledby
// aria-label:內聯字串標籤 — 當沒有可見的標籤文字時使用
<button aria-label="關閉模態視窗">
<XIcon />
</button>
// aria-labelledby:引用另一個元素的文字 — 當存在可見標籤時使用
<section aria-labelledby="section-title">
<h2 id="section-title">近期訂單</h2>
{/* 內容 */}
</section>
aria-describedby
// 提供標籤之外的補充描述
<button
aria-describedby="delete-warning"
onClick={handleDelete}
> 刪除帳戶
</button>
<p id="delete-warning">此操作無法復原。</p>
用於動態內容的 aria-live
// 使用 aria-live 來宣告無需重新載入頁面即可更新的內容
// polite:等待使用者完成當前操作後再進行宣告
// assertive:立即打斷 — 僅用於緊急錯誤
export function StatusMessage({ message, isError }: { message: string; isError?: boolean }) {
return (
<div role="status" aria-live={isError ? 'assertive' : 'polite'} aria-atomic="true">
{message}
</div>
);
}
aria-expanded 和 aria-controls
export function Accordion({ title, children }: { title: string; children: React.ReactNode }) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button aria-expanded={isOpen} aria-controls={contentId} onClick={() => setIsOpen(prev => !prev)}>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}
鍵盤導航
每個互動元素都必須能僅透過鍵盤觸及和操作。
自訂下拉選單
export function Dropdown({ options, onSelect }: { options: string[]; onSelect: (value: string) => void }) {
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const listId = useId();
if (!options.length) return null;
const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setActiveIndex(i => Math.min(i + 1, options.length - 1));
break;
case 'ArrowUp':
e.preventDefault();
setActiveIndex(i => Math.max(i - 1, 0));
break;
case 'Enter':
case ' ':
e.preventDefault();
if (isOpen) onSelect(options[activeIndex]);
setIsOpen(prev => !prev);
break;
case 'Escape':
setIsOpen(false);
break;
}
};
return (
<div
role="combobox"
aria-expanded={isOpen}
aria-haspopup="listbox"
aria-controls={listId}
tabIndex={0}
onKeyDown={handleKeyDown}
onClick={() => setIsOpen(prev => !prev)}
>
<span>{options[activeIndex]}</span>
{isOpen && (
<ul id={listId} role="listbox">
{options.map((option, index) => (
<li
key={option}
role="option"
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
焦點管理
當 UI 狀態改變時,焦點必須邏輯地移動 — 尤其是對於模態視窗和路由轉換。
模態視窗焦點恢復
此範例涵蓋初始焦點和恢復。對於完整的焦點陷阱(在模態視窗內循環 Tab/Shift+Tab),請使用像
focus-trap-react這樣的函式庫,它可以處理動態內容和巢狀入口等邊緣情況。
export function Modal({ isOpen, onClose, title, children }: { isOpen: boolean; onClose: () => void; title: string; children: React.ReactNode }) {
const modalRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
useEffect(() => {
if (isOpen) {
// 儲存當前聚焦的元素並將焦點移至模態視窗內
previousFocusRef.current = document.activeElement as HTMLElement;
modalRef.current?.focus();
} else {
// 將焦點恢復到開啟模態視窗的元素
previousFocusRef.current?.focus();
}
}, [isOpen]);
if (!isOpen) return null;
return (
<div ref={modalRef} role="dialog" aria-modal="true" aria-labelledby="modal-title" tabIndex={-1} onKeyDown={e => e.key === 'Escape' && onClose()}>
<h2 id="modal-title">{title}</h2>
{children}
<button onClick={onClose}>關閉</button>
</div>
);
}
圖片和圖示
// 不好:裝飾性圖示被宣告為無標籤圖片
<img src="/icon.svg" />
// 好:裝飾性圖片對螢幕閱讀器隱藏
<img src="/decoration.png" alt="" aria-hidden="true" />
// 好:有意義的圖片附帶描述性的替代文字
<img src="/chart.png" alt="每月營收從一月到三月增長了 23%" />
// 好:帶有無障礙標籤的圖示按鈕
<button aria-label="刪除項目">
<TrashIcon aria-hidden="true" />
</button>
減少動畫
尊重那些在其作業系統設定中要求減少動畫的使用者。
export function useReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
return prefersReduced;
}
// 使用方式
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const reduceMotion = useReducedMotion();
return (
<div
style={{
transition: reduceMotion ? 'none' : 'transform 300ms ease'
}}
>
{children}
</div>
);
}
反模式
// 不好:在非互動元素上使用 onClick,沒有鍵盤支援
<div onClick={handleClick}>點擊我</div>
// 不好:在沒有角色的 div 上使用 aria-label
<div aria-label="導航">...</div>
// 不好:使用 placeholder 替代 label
<input placeholder="請輸入您的電子郵件" />
// 不好:正數的 tabIndex 建立了不可預測的頁籤順序
<button tabIndex={3}>提交</button>
// 不好:在可聚焦元素上使用 aria-hidden — 鍵盤使用者會被困住
<button aria-hidden="true">開啟</button>
// 不好:在沒有鍵盤處理程式的 div 上使用 role="button"
<div role="button" onClick={handleClick}>提交</div>
// 缺少:tabIndex={0}, 用於 Enter/Space 的 onKeyDown
檢查清單
在提交任何互動式元件進行審查之前:
- 每個
<input>、<select>和<textarea>都透過htmlFor/id連接了一個<label> - 錯誤訊息已透過
aria-describedby連結,並標記了role="alert" - 沒有在沒有
role、tabIndex和onKeyDown的情況下,在<div>或<span>上使用onClick - 純圖示按鈕具有
aria-label - 裝飾性圖片使用
alt=""和aria-hidden="true" - 模態視窗在關閉時恢復焦點(對於完整的 Tab/Shift+Tab 循環焦點陷阱,請使用像
focus-trap-react這樣的函式庫) - 動態內容更新使用
aria-live - 動畫須遵守
prefers-reduced-motion
相關技能
frontend-patterns— 通用的 React 元件和狀態模式design-system— 設計符記和元件一致性motion-foundations和motion-patterns:考慮無障礙性的動畫模式

