フロントエンドアクセシビリティパターン
React および Next.js 向けの実践的なアクセシビリティパターンです。コードレビューで最も頻繁に指摘される問題(フォームラベルの欠落、ARIA の誤使用、非セマンティックなインタラクティブ要素、キーボードナビゲーションの不備)をカバーします。
アクティブ化のタイミング
- フォームコンポーネント(
<input>、<select>、<textarea>)の構築またはレビュー時 - インタラクティブ要素(モーダル、ドロップダウン、ツールチップ、タブ)の作成時
onClickを伴う<div>または<span>の使用時- 任意の要素への
aria-*属性の追加時 - キーボードナビゲーションまたはフォーカス管理の実装時
- コードレビューツール(CodeRabbit、ESLint a11y)からのアクセシビリティフィードバック受領時
- スクリーンリーダーをサポートする必要があるコンポーネントの構築時
フォームアクセシビリティ
htmlFor / id のペアリングの欠落と、エラーメッセージの接続断は、コードレビューで最も一般的に指摘される問題です。
ラベルの接続
// BAD: ラベルが入力と接続されていない — スクリーンリーダーはそれらを関連付けられない
<label>Email</label>
<input type="email" />
// GOOD: htmlFor が input の id と一致している
<label htmlFor="email">Email</label>
<input id="email" type="email" />
必須フィールド
// BAD: 視覚的なアスタリスクのみで、スクリーンリーダーには何も伝わらない
<label htmlFor="email">Email *</label>
<input id="email" type="email" />
// GOOD: required によりネイティブブラウザ検証が有効に。aria-required でスクリーンリーダーに通知
<label htmlFor="email">
Email <span aria-hidden="true">*</span>
</label>
<input id="email" type="email" required aria-required="true" />
エラーメッセージ
// BAD: エラーテキストは視覚的に存在するが、入力とリンクされていない
<input id="email" type="email" />
<span className="error">無効なメールアドレスです</span>
// GOOD: 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
意図に合った要素を使用してください。スクリーンリーダーとキーボードユーザーはネイティブのセマンティクスに依存します。
// BAD: div にはロール、キーボードサポート、アクセシブル名がない
<div onClick={handleClick}>送信</div>
// GOOD: button はフォーカス可能で、Enter/Space でアクティブ化され、「ボタン」として通知される
<button type="button" onClick={handleClick}>送信</button>
// BAD: 非セマンティックなナビゲーション
<div onClick={() => navigate('/home')}>ホーム</div>
// GOOD: a 要素は右クリック、中クリック、キーボードナビゲーションをサポート
<a href="/home">ホーム</a>
// BAD: 見出し階層がスキップされている(h1 から h4)
<h1>ダッシュボード</h1>
<h4>最近のアクティビティ</h4>
// GOOD: 連続した見出しレベル
<h1>ダッシュボード</h1>
<h2>最近のアクティビティ</h2>
ARIA属性
ARIA は、ネイティブの HTML セマンティクスで不十分な場合にのみ使用してください。間違った 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>
);
}
画像とアイコン
// BAD: 装飾アイコンがラベルのない画像として通知される
<img src="/icon.svg" />
// GOOD: 装飾画像をスクリーンリーダーから隠す
<img src="/decoration.png" alt="" aria-hidden="true" />
// GOOD: 意味のある画像に説明的な代替テキストを付ける
<img src="/chart.png" alt="月間収益が1月から3月にかけて23%増加" />
// GOOD: アクセシブルなラベルを持つアイコンボタン
<button aria-label="アイテムを削除">
<TrashIcon aria-hidden="true" />
</button>
動きの軽減
OS 設定で動きの軽減をリクエストしているユーザーを尊重します。
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>
);
}
アンチパターン
// BAD: インタラクティブでない要素への onClick(キーボードサポートなし)
<div onClick={handleClick}>クリックしてください</div>
// BAD: ロールのない div への aria-label
<div aria-label="ナビゲーション">...</div>
// BAD: ラベルの代用としての placeholder
<input placeholder="メールアドレスを入力" />
// BAD: 正の tabIndex は予測不可能なタブ順序を作成する
<button tabIndex={3}>送信</button>
// BAD: フォーカス可能な要素への aria-hidden — キーボードユーザーがトラップされる
<button aria-hidden="true">開く</button>
// BAD: キーボードハンドラのない 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: アクセシビリティを考慮したアニメーションパターン

