building-accessible-interfaces について
WCAG 2.2 レベル AA に沿ったインターフェースの設計と検証を支援する実践的なスキルです。ネイティブなセマンティック HTML、キーボード操作、アクセシブルな名前、コントラスト、フォーム、動的更新を重視し、自動検査・キーボード操作・スクリーンリーダーでの確認を扱います。
主な機能
- 01ネイティブなセマンティック HTML パターン
- 02キーボード操作とフォーカス管理
- 03アクセシブルな名前とコントラストの確認
- 04アクセシブルなフォームと動的通知
- 05自動・キーボード・スクリーンリーダー検証
活用シーン
- 01アクセシブルなウェブコントロールの構築
- 02キーボード操作とフォーカス動作の確認
- 03フォームラベルとエラー処理の改善
- 04WCAG 2.2 AA に沿った画面検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ osmontero/ opencode-skills --skill building-accessible-interfacesインストール後、Claude Code で/building-accessible-interfacesを入力して読み込まれたか確認します。
詳細
アクセシブルなインターフェースの構築
概要
アクセシビリティは、最後に適用するチェック項目ではありません。アクセシビリティの不具合のほとんどは構造上の判断に起因します。button にすべき div、色だけで伝えられる状態、フォーカスが移動しないモーダルなどです。構造上の判断を覆すには、多大なコストがかかります。
基本原則: ネイティブのセマンティック HTML は、デフォルトでアクセシブルです。その上に追加するレイヤーについては、アクセシビリティを自分で担保する必要があります。
目標:WCAG 2.2 レベル AA。
絶対原則
NO CUSTOM CONTROL WHERE A NATIVE ELEMENT EXISTS
<div onclick> を使うには、ロール、tabindex、Enter と Space のキーイベントハンドラー、フォーカススタイル、無効状態が必要です。一方、<button> はそのどれも必要ありません。あらゆるプラットフォームと支援技術で正しく機能するものが、最初からすべて備わっています。
<div onclick> を書きましたか?<button> に置き換えてください。div をボタンのように動作させるために ARIA を追加してはいけません。ARIA が変えるのは支援技術による読み上げ方であり、ブラウザーの動作ではありません。
チェックリスト
- まずセマンティクス — 適切な要素、ランドマーク、見出しの順序
- キーボード — すべて操作可能、フォーカスが見える、順序が論理的、トラップなし
- 名前 — すべてのコントロールと画像にアクセシブルな名前がある
- コントラスト — テキストは 4.5:1、大きなテキストと UI の境界は 3:1
- フォーム — ラベル、エラーとの関連付け、通知
- 動的コンテンツ — ナビゲーションなしで変化するコンテンツにはライブリージョンを使用
- モーションとターゲット — モーション低減設定を尊重し、ターゲットは 24px 以上(44px 推奨)
- 検証 — 自動スキャン、キーボードでの確認、スクリーンリーダーでの確認の順に行う
1. セマンティクス
適切な要素を使う
| 用途 | 要素 | 使用しないもの |
|---|---|---|
| URL に移動する | <a href> | <button onclick=location…> |
| 操作を実行する | <button> | <div onclick>、<a href="#"> |
| 選択肢のグループから 1 つ選ぶ | <input type="radio"> | スタイルを適用した div |
| コンテンツの表示/非表示 | <details><summary> | div + JS による切り替え |
| モーダルダイアログ | <dialog> | position: fixed を指定した div |
| リストから選択する | <select> | 本当に必要な場合を除き、カスタムリストボックス |
| 表形式データ | <th scope> を含む <table> | div のグリッド |
| 進捗 | <progress> | アニメーション付き div |
<a> と <button> の違いは見た目だけの問題ではありません。リンクでは新しいタブで開く、リンクをコピーすることができ、Enter のみで有効化します。ボタンは Enter と Space の両方で有効化します。スクリーンリーダーのユーザーは要素の種類で移動するため、リンク一覧に「ボタン」があると、実際に誤解を招きます。
ランドマーク
<main> はページに 1 つだけにします。ナビゲーションは <nav> で囲みます。<header>、<footer>、<aside> を使用します。スクリーンリーダーのユーザーはランドマーク間を頻繁に移動します。入れ子になった div だけのページでは、移動先がありません。
<a href="#main" class="skip-link">Skip to content</a>
<header>…</header>
<nav aria-label="Primary">…</nav>
<main id="main" tabindex="-1">…</main>
<footer>…</footer>
複数の <nav> 要素を使う場合、それぞれに区別できる aria-label(「Primary」、「Breadcrumb」、「Pagination」)が必要です。
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: var(--space-s); top: var(--space-s); z-index: 9999; }
見出し
ページごとに <h1> を 1 つ使います。レベルを飛ばしてはいけません(h2 → h4 は不具合です)。見出しレベルは文書構造を表します。構造に合わせてレベルを選び、サイズは CSS で設定します。
2. キーボード
すべてのインタラクティブ要素は、キーボードだけでフォーカスでき、操作できなければなりません。 このドキュメントで最も効果の高い確認方法で、90 秒でできます。マウスを外し、Tab キーでページ内を移動してください。
| キー | 期待される動作 |
|---|---|
Tab / Shift+Tab | DOM 順にフォーカス可能な要素間を移動する |
Enter | リンクまたはボタンを有効化する |
Space | ボタンを有効化する、チェックボックスを切り替える、ページをスクロールする |
Esc | モーダル、ポップオーバー、メニューを閉じる。現在の操作をキャンセルする |
Arrows | ラジオグループ、タブ、メニュー、リストボックスなどの複合ウィジェット内を移動する |
Home / End | 複合ウィジェット内の最初/最後の項目に移動する |
ロービング tabindex — 複合ウィジェット全体を 1 つ の Tab 停止位置とし、矢印キーで内部を移動します。
// Only the active item is tabbable; arrows move the active item.
items.forEach((el, i) => el.tabIndex = i === active ? 0 : -1);
タブ、メニュー、ツールバー、リストボックスでは、いずれもこの方法を使います。各タブが個別の Tab 停止位置になっているタブリストは不適切です。
フォーカスの視認性
:focus-visible {
outline: 2px solid var(--focus, currentColor);
outline-offset: 2px;
border-radius: 2px;
}
:focus:not(:focus-visible) { outline: none; } /* no ring on mouse click */
置き換えを用意せずに outline: none を指定してはいけません。 フォーカスインジケーターは隣接する色とのコントラスト比を 3:1 以上にし、太さを 2px 以上にする必要があります(WCAG 2.2 SC 2.4.11/2.4.13)。
フォーカス管理
コンテキストが変わったときはフォーカスを移動し、閉じたときは元に戻す必要があります。
// Opening
lastFocused = document.activeElement;
dialog.showModal(); // <dialog> traps focus natively
dialog.querySelector('h2').focus(); // or the first meaningful control
// Closing
dialog.close();
lastFocused?.focus(); // MUST return focus
モーダルの外にフォーカスを閉じ込めないでください。 Esc キーで抜け出せないフォーカストラップは WCAG 2.1.2 のレベル A の不適合です。キーボードユーザーがページ上で永久に操作できなくなるおそれがあります。
SPA のルート変更時: フォーカスを新しいページの <h1> または <main tabindex="-1"> に移動し、新しいタイトルを通知します。これがないと、スクリーンリーダーのユーザーはページ遷移が発生したことに気づけません。
予告なくフォーカスを移動しないでください。 入力中にフィールドへ自動でフォーカスしたり、バックグラウンドでの更新時にフォーカスを飛ばしたりすると、誰もが戸惑います。
3. アクセシブルな名前
すべてのコントロールには名前が必要です。開発者ツールの「アクセシビリティ」ペインで、意図したマークアップではなく、計算後の名前を確認してください。
<label for="q">Search</label><input id="q">
<button aria-label="Close dialog"><svg aria-hidden="true">…</svg></button>
<section aria-labelledby="h-recent"><h2 id="h-recent">Recent</h2>…</section>
ルール:
- 装飾画像:
alt=""を指定します。altを省略すると、スクリーンリーダーはファイル名を読み上げます。 - 情報を伝える画像:
altでは画像そのものではなく、情報を説明します。グラフのaltには、そのグラフから分かる内容を記述します。 - ラベル付きコントロール内のアイコン:名前が重複しないよう、
aria-hidden="true"を指定します。 - リンクテキストは、前後の文脈がなくても意味が通じる必要があります。ページ内に「続きを読む」が8個あっても役に立ちません。スクリーンリーダーのユーザーはリンクの一覧を表示するためです。
aria-labelは表示テキストを上書きします。ボタンに「保存」と表示されていても、aria-label="送信"があると、音声操作ユーザーが「保存をクリック」と言っても操作できません。両者を一致させてください。
4. コントラスト
| コンテンツ | 比率 |
|---|---|
| 本文 | 4.5:1 |
| 24px 以上のテキスト、または 18.66px 以上の太字テキスト | 3:1 |
| 意味を伝えるアイコンと UI の境界 | 3:1 |
| フォーカスインジケーターと隣接する色 | 3:1 |
推測せず、測定してください。計算式は designing-frontend-interfaces/references/color-and-theme.md を、実行可能なチェッカーは applying-themes/scripts/check_contrast.py を参照してください。
色だけで情報を伝えないでください。 色で状態を示すすべての箇所に、アイコン、ラベル、パターン、形状のいずれかを追加してください。同じリファレンスの表を参照してください。
5. フォーム
<div class="field">
<label for="email">Email address</label>
<p id="email-hint" class="hint">We'll only use this to send your receipt.</p>
<input id="email" type="email" inputmode="email" autocomplete="email"
aria-describedby="email-hint email-err" aria-invalid="true" required>
<p id="email-err" class="error">Enter an email address with an @.</p>
</div>
| ルール | 理由 |
|---|---|
すべての入力に <label for> を指定する | プレースホルダーはラベルではありません。フォーカスすると消え、コントラストの要件も満たしません |
エラーを aria-describedby で関連付ける | そうしないと、スクリーンリーダーからエラーが見えません |
無効なフィールドに aria-invalid="true" を指定する | メッセージだけでなく、状態も通知します |
ヒントとエラーの両方を aria-describedby に指定する | ID をスペース区切りで並べると、両方が読み上げられます |
バリデーションのために送信ボタンを disabled にしない | 無効な要素は Tab キーでスキップされ、その理由も伝わりません |
関連する入力を <fieldset><legend> でグループ化する | ラジオボタンやチェックボックスのセットでは、グループ名を読み上げる必要があります |
赤いアスタリスクだけでなく required 属性を使う | ブラウザーが標準で通知します |
送信に失敗した場合は、最初の無効なフィールド、または各エラーへのリンクを含む概要にフォーカスを移動します。
6. ライブリージョン
ページ遷移なしで変化するものは、すべて通知する必要があります。
<div aria-live="polite" aria-atomic="true" class="sr-only" id="status"></div>
<div role="alert" id="errors"></div>
| リージョン | 用途 | 割り込み |
|---|---|---|
aria-live="polite" | 状態、検索結果の件数、保存の確認 | いいえ。一時停止を待ちます |
role="alert" / assertive | エラー、セッションの期限切れ、破壊的な操作に関する警告 | はい |
重要: ライブリージョンの要素は、コンテンツが変わる前に DOM に存在している必要があります。テキストがすでに入った要素を挿入しても、何も読み上げられません。
// ✅ Region already present; only its text changes
status.textContent = `${n} results`;
// ❌ Injecting a populated live region — silent
container.innerHTML = '<div aria-live="polite">12 results</div>';
使用は控えめにしてください。3つのライブリージョンが競合すると、使いものにならないほど読み上げが騒がしくなります。
.sr-only {
position: absolute; width: 1px; height: 1px; padding: 0;
margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.sr-only のコンテンツも、コントロールを含んでいる場合はフォーカス可能です。スキップリンクを表示するには、.sr-only:focus-within のスタイルを使ってください。
7. モーションとターゲット
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .01ms !important; animation-iteration-count: 1 !important;
transition-duration: .01ms !important; scroll-behavior: auto !important;
}
}
- 1秒間に3回を超えて点滅するコンテンツを作らないでください(発作のリスク、WCAG 2.3.1)。
- 5秒を超えて自動再生するモーションには、一時停止コントロールが必要です(WCAG 2.2.2)。
- カルーセルには一時停止機能が必要です。また、フォーカス中に自動で次へ進めないでください。
ターゲットサイズ(WCAG 2.2 SC 2.5.8、AA): 最小で 24×24 CSS px です。実用上は 44×44 を推奨します。 見た目のサイズは変えずに、クリック領域を広げてください。
.icon-btn { position: relative; }
.icon-btn::after { content:""; position:absolute; inset:-10px; }
8. ARIA の 5 つのルール
- 必要なセマンティクスを持つネイティブ要素がある場合は、代わりにそれを使用します。
- ネイティブのセマンティクスを変更しないでください —
<button role="heading">は誤りです。代わりにラップします。 - インタラクティブな ARIA はすべてキーボードで操作できるようにします —
role="button"を指定した div には、tabindex="0"と Enter/Space のハンドラーが必要です。 - フォーカス可能な要素に
role="presentation"やaria-hidden="true"を決して指定しないでください — フォーカス可能なのに読み上げられなくなり、最悪の状態になります。 - すべてのインタラクティブ要素にアクセシブルな名前が必要です。
不適切な ARIA より、ARIA を使わないほうがよいです。 不適切な ARIA は積極的に誤った情報を伝えます。ARIA がなければ、ネイティブの動作にフォールバックするだけです。
コンポーネントのパターン
ダイアログ
<dialog id="confirm" aria-labelledby="confirm-title">
<h2 id="confirm-title">Delete 47 records?</h2>
<p>Records in Q3 Archive will be permanently removed.</p>
<button data-close>Keep records</button>
<button data-confirm class="danger">Delete 47 records</button>
</dialog>
dialog.showModal() は、フォーカスのトラップ、Esc キーでの閉じる操作、背景の不活性化、::backdrop 疑似要素をネイティブで提供します。それでも必要な作業として、開いたときに適切な要素へフォーカスを移し、閉じたときにフォーカスを元に戻してください。
ディスクロージャー
<button aria-expanded="false" aria-controls="panel-1">Shipping details</button>
<div id="panel-1" hidden>…</div>
aria-expanded と hidden の両方を切り替えます。または <details><summary> を使えば、JavaScript は一切不要です。
タブ
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected="true" aria-controls="p1" id="t1" tabindex="0">Profile</button>
<button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">Billing</button>
</div>
<div role="tabpanel" id="p1" aria-labelledby="t1" tabindex="0">…</div>
<div role="tabpanel" id="p2" aria-labelledby="t2" tabindex="0" hidden>…</div>
ロービング tabindex を使用します。矢印キーでタブ間を移動し、Home/End キーで最初/最後のタブに移動します。
トースト
<div aria-live="polite" id="toasts"></div>
エラーには role="alert" を使用します。ユーザーの操作が必要なものを自動的に消さないでください — WCAG 2.2.1 では、時間制限を調整できることが求められています。
コンボボックス
一般的なパターンの中で最も難しいものです。独自実装するのではなく、メンテナンスされているライブラリ(React Aria、Radix、Headless UI)を使用してください。独自実装する場合は、aria-expanded、aria-controls、ハイライトされているオプションの id を指す aria-activedescendant を指定した role="combobox" を使用します — 矢印キーでハイライトを移動しても、フォーカスは入力欄に留まります。
検証
自動化ツールで検出できる問題は、およそ 30~40%です。3 つすべての確認が必要です。
1. 自動スキャン
npx @axe-core/cli http://localhost:5173 --exit
npx lighthouse http://localhost:5173 --only-categories=accessibility --view
または、Playwright テスト内で実行します — testing-webapps スキルを参照してください。
page.add_script_tag(url="https://cdn.jsdelivr.net/npm/axe-core@4/axe.min.js")
violations = page.evaluate("async () => (await axe.run()).violations")
assert not violations, [v["id"] for v in violations]
2. キーボードでの確認 — マウスを使わずに操作します:
- Tab キーで、視覚的なレイアウトに沿った順序ですべてのコントロールに移動できる
- フォーカスは常に見える — 失われたり、どの背景上でも見えなくなったりしない
- Enter と Space で、対応する操作が実行される
- Esc ですべてのオーバーレイが閉じる
- タブ、メニュー、ラジオグループ内で矢印キーが機能する
- モーダルを開くとフォーカスが中に移動し、閉じるとトリガーに戻る
- ホバーでしかアクセスできないものがない
- モーダルの外にフォーカストラップがない
3. スクリーンリーダー — 実際のスクリーンリーダーを 5 分間使います:
| プラットフォーム | リーダー | 起動/停止 |
|---|---|---|
| macOS | VoiceOver | Cmd+F5 |
| Windows | NVDA(無料) | Ctrl+Alt+N |
| Linux | Orca | Super+Alt+S |
見出し(H)、ランドマーク(D)、フォームコントロール(F)で移動します。すべてのコントロールで、名前、ロール、状態が読み上げられる必要があります。
よくある間違い
<button> の代わりに <div onclick> を使う。 最もよくある不具合です。キーボードで操作できず、ロールもフォーカスも無効状態もありません。
代替手段なしで outline: none を指定する。 キーボード利用者が現在位置を知るための唯一の手がかりを取り除いてしまいます。
ラベルの代わりにプレースホルダーを使う。 フォーカスすると消え、コントラストが不足し、自動入力が機能せず、スクリーンリーダーでの読み上げも不安定です。
モーダルからフォーカスが戻らない。 フォーカスが <body> に戻り、ページ全体を Tab キーで移動し直すことになります。
コンテンツと同時にライブリージョンを挿入する。 何も読み上げられません。要素はあらかじめ存在している必要があります。
aria-label が表示テキストと矛盾している。 音声コントロールが機能しなくなります — ユーザーが目に見える単語を発話しても、何も起こりません。
名前のないアイコンのみのボタン。 「ボタン」とだけ読み上げられます。アイコンボタンにはすべて aria-label が必要です。
見出しレベルを文字サイズで選ぶ。 見た目が適切だからと <h4> を選んでしまうケースです。レベルは構造に合わせ、サイズは CSS で指定します。
ライトモードでしかコントラストを確認しない。 ダークモードの確認も別途必要です。
フォーカス可能な要素に aria-hidden="true" を指定する。 Tab キーで移動できるのに、何も読み上げられません。非表示のコンテナには、代わりに inert を使用してください。