キーボード操作とフォーカス管理のアクセシビリティ完全ガイド:順序・可視化・ロービングTabIndex・ショートカット・モーダル/タブ/メニューまで
概要サマリー(先に要点)
- Tab順=論理順を守り、最初のTabでスキップリンク→本文に入れる“地図”を用意します。
- フォーカス可視化は太く・コントラスト十分にし、
:focus-visibleを活用。非テキスト3:1を満たします。- 複数子要素を持つUI(タブ・メニュー・グリッド)はロービングTabIndexでTab=領域間/矢印=領域内に。
- モーダルは開閉・フォーカストラップ・Esc閉じ・復帰の4点セット。
inert/aria-hiddenで背景を静めます。- ショートカットは宣言と衝突回避を徹底。必ず**代替操作(クリック/キーボード標準)**を提供し、学習しやすい記号化を。
- 5分で回せるスモークテストと、現場で使える実装スニペット(HTML/ARIA/CSS/JS)を多数収録。
対象読者(具体):フロントエンドエンジニア、UI/UXデザイナー、プロダクトマネージャー、QA、テクニカルライター、Web編集者、デザインシステム運用担当
アクセシビリティレベル:WCAG 2.1 AA 準拠(可能箇所は 2.2/2.5 の拡張項目も推奨)
1. はじめに:アクセシビリティは“フォーカスの旅路”から
利用者は、Tabキーでリンクやボタンを巡り、矢印キーで項目を選び、Enter/Spaceで実行します。スクリーンリーダーに限らず、トラックパッドや外付けキーボード、口で操作するスティック、スイッチコントロールなど、多様な入力手段でWebは使われていますの。
ところが、見えないフォーカスや飛び回る順序、閉じられないモーダルは、誰にとってもストレスの源。順序・可視化・操作の一貫性が整うだけで、体験は劇的に落ち着きます。
本記事では、原則→代表パターン→コード→テスト→運用の順で、実務にそのまま使える“型”をお届けしますね。
2. 大原則:順序・名前・見え方
2.1 Tab順=論理順
- DOM順=読み順=Tab順が基本。視覚レイアウトで並べ替えるならソース順を優先し、CSSの
order等で視覚だけを変えます。 tabindex="0"はフォーカス可能化に、正の値(1,2…)は原則禁止(順序の崩壊を招きます)。
2.2 名前・役割・値(NRV)
- 名前:可視ラベル=アクセシブルネーム(2.5.3 Label in Name)。
- 役割:
button/tab/tabpanel/menuitemなどネイティブか相当ARIA。 - 値:
aria-checked/aria-expanded/aria-selected/valueNow等で状態を露出。
2.3 フォーカスの「見え方」
- 太さ3px以上、アウトラインオフセット2–3px、背景とのコントラストを確保。
:focus-visibleでキーボード時のみ明確表示、マウス時は控えめに。
:focus-visible {
outline: 3px solid #FF9900;
outline-offset: 3px;
}
3. スキップリンクとランドマーク:最初の“一歩”を短く
- ページ冒頭にスキップリンクを置き、本文(
main)へ移動できるように。 - 複数の
navやasideには**aria-label**で役割名(例:「グローバル」「フッター」)を。
<a class="skip" href="#content">本文へスキップ</a>
<main id="content" tabindex="-1">…</main>
.skip{ position:absolute; left:-9999px; }
.skip:focus{ left:1rem; top:1rem; background:#111; color:#fff; padding:.5rem .75rem; border-radius:.5rem; }
4. ロービングTabIndex:複合コンポーネントの定石
狙い:Tabでコンポーネント全体を移動、矢印で内部アイテムを移動して、Tabストリームを短く保つ。
4.1 タブ(Tabs)
- 構造:
role="tablist"内のbutton[role="tab"]、対応するdiv[role="tabpanel"]。 - キーボード:左右矢印で隣のタブ、
Home/Endで先頭/末尾、Space/Enterで選択。
<div role="tablist" aria-label="請求情報" id="tabs">
<button role="tab" aria-selected="true" aria-controls="p1" id="t1" tabindex="0">明細</button>
<button role="tab" aria-selected="false" aria-controls="p2" id="t2" tabindex="-1">支払い方法</button>
<button role="tab" aria-selected="false" aria-controls="p3" id="t3" tabindex="-1">設定</button>
</div>
<section id="p1" role="tabpanel" aria-labelledby="t1">…</section>
<section id="p2" role="tabpanel" aria-labelledby="t2" hidden>…</section>
<section id="p3" role="tabpanel" aria-labelledby="t3" hidden>…</section>
const tabs = document.querySelectorAll('[role="tab"]');
const panels = document.querySelectorAll('[role="tabpanel"]');
function activateTab(tab){
tabs.forEach(t=>{ t.setAttribute('tabindex', t===tab? '0':'-1'); t.setAttribute('aria-selected', String(t===tab)); });
panels.forEach(p=> p.hidden = (p.id !== tab.getAttribute('aria-controls')));
tab.focus();
}
document.getElementById('tabs').addEventListener('keydown', e=>{
const list = [...tabs]; const i = list.indexOf(document.activeElement);
if(e.key==='ArrowRight') activateTab(list[(i+1)%list.length]);
if(e.key==='ArrowLeft') activateTab(list[(i-1+list.length)%list.length]);
if(e.key==='Home') activateTab(list[0]);
if(e.key==='End') activateTab(list[list.length-1]);
});
tabs.forEach(t=> t.addEventListener('click', ()=>activateTab(t)));
4.2 メニュー(Disclosure型ナビ)
- トリガは
<button aria-expanded>で、開くと最初の項目にtabindex="0"。 - 矢印で前後移動、
Escで閉じてトリガに復帰。
<div class="menu">
<button id="menuBtn" aria-expanded="false" aria-controls="m1">アカウント</button>
<ul id="m1" role="menu" hidden>
<li><a role="menuitem" href="/profile" tabindex="0">プロフィール</a></li>
<li><a role="menuitem" href="/settings" tabindex="-1">設定</a></li>
<li><button role="menuitem" tabindex="-1">ログアウト</button></li>
</ul>
</div>
const btn = document.getElementById('menuBtn'), menu = document.getElementById('m1');
const items = ()=> [...menu.querySelectorAll('[role="menuitem"]')];
btn.addEventListener('click', ()=>{
const open = btn.getAttribute('aria-expanded')==='true';
btn.setAttribute('aria-expanded', String(!open));
menu.hidden = open;
if(!open) items()[0].focus();
});
menu.addEventListener('keydown', e=>{
const list = items(); const i = list.indexOf(document.activeElement);
if(e.key==='ArrowDown') list[(i+1)%list.length].focus();
if(e.key==='ArrowUp') list[(i-1+list.length)%list.length].focus();
if(e.key==='Escape'){ btn.click(); btn.focus(); }
});
注意:サイトのナビにはネイティブ
ul/li/a+ボタン開閉で十分。role="menu"はアプリメニューの意味が強いため乱用は避けるのが安全です。
4.3 グリッド(カード群・日付ピッカー)
- グリッドは
role="grid"、セルにrole="gridcell"。行/列の矢印移動を実装し、選択はEnter/Space。
5. モーダルダイアログ:開閉・トラップ・復帰・告知
5.1 必須の4点セット
- 開く:最初に操作すべき要素へフォーカス移動。
- トラップ:ダイアログ内でTab循環、背景は**
inert**(対応外はaria-hidden="true")。 - Esc閉じ:
Escapeで閉じる。 - 復帰:閉じたらトリガ要素へフォーカスを戻す。
<button id="open">設定を開く</button>
<dialog id="dlg" aria-labelledby="dlg-title">
<h2 id="dlg-title">通知設定</h2>
<label><input type="checkbox"> メール通知を受け取る</label>
<div class="actions">
<button id="save">保存</button>
<button id="close">閉じる</button>
</div>
</dialog>
const openBtn = document.getElementById('open');
const dlg = document.getElementById('dlg');
let lastFocus;
openBtn.addEventListener('click', ()=>{
lastFocus = document.activeElement;
dlg.showModal(); // <dialog>が使えない環境ではロールバック実装を
dlg.querySelector('input,button,select,textarea,[tabindex="0"]')?.focus();
});
document.getElementById('close').addEventListener('click', ()=>{ dlg.close(); lastFocus?.focus(); });
dlg.addEventListener('cancel', (e)=>{ e.preventDefault(); dlg.close(); lastFocus?.focus(); }); // Esc
<dialog>非対応時は、背景にaria-hidden="true"を付与、bodyをoverflow:hiddenにし、ダイアログ内でフォーカストラップを実装します。
6. フォーカス可視のデザイン:見失わせない
- 非テキスト3:1(枠・アイコン・フォーカスリング)を満たす色設計。
- 太さ:2–3px、オフセット:2–4pxで“にじみ”を回避。
- ダーク/ライトの両テーマで見える色を用意し、
prefers-contrast: moreで太さ強化。
:root{ --focus:#FF9900; }
:focus-visible{ outline:3px solid var(--focus); outline-offset:3px; }
@media (prefers-contrast: more){
:focus-visible{ outline-width:4px; }
}
7. ショートカット設計:速く、でも“必ず学べて代替可能”
- 必ず代替操作(ボタン/メニュー/通常のキーボード操作)を提供。
- 記法を統一(例:Win/Linux=Ctrl、macOS=⌘)。
- 衝突回避:ブラウザ予約(例:Ctrl+L, Ctrl+T, ⌘+R)は避ける。
- 表示:ヘルプやツールチップ、メニュー項目に併記。
<button aria-keyshortcuts="Ctrl+K" aria-label="検索(Ctrl+K)">検索</button>
window.addEventListener('keydown', (e)=>{
const mac = navigator.platform.includes('Mac');
if((mac? e.metaKey : e.ctrlKey) && e.key.toLowerCase()==='k'){ e.preventDefault(); document.getElementById('search').focus(); }
});
8. フォーカス順序の設計パターン:実践集
8.1 カード一覧 → 詳細
- 一覧(
role="region")→カードの見出しリンク→内部の主要操作→次のカード。 - 「もっと見る」は最後に。見出し→本文→操作の順で反復パターンを作る。
8.2 フィルタと結果
- フィルタを先に置く(スキップリンクで結果へ飛ぶ導線も)。
- 適用→結果にフォーカスで“反応があった”ことを示す(
role="status")。
8.3 エラー時
- エラー要約(
role="alert")へフォーカス移動→各項目のリンクで現場へジャンプ。
9. マウス/タッチ前提UIの代替:ドラッグ・ホバー・長押し
- ドラッグ操作の代替:ボタン(上へ/下へ/左へ/右へ)やメニューで同じ結果を提供(WCAG 2.5.7)。
- ホバー依存はフォーカス時にも表示(ツールチップ、メガメニュー)。
- 長押しはクリック/Enterで代替、時間制約を緩く。
<div role="listbox" aria-label="並び順">
<button aria-label="1つ上へ">↑</button>
<button aria-label="1つ下へ">↓</button>
</div>
10. ライブ地域と状態通知:静かに、確実に伝える
role="status":保存完了など非割り込み。role="alert":エラーや重要障害など割り込み。aria-live="polite":検索ヒット数など自動更新の実況。
<div id="status" role="status" aria-atomic="true" class="sr-only"></div>
<script>
function saved(){ document.getElementById('status').textContent='保存が完了しました。'; }
</script>
11. よくある落とし穴と回避策
| 落とし穴 | 何が起きる? | 回避策 |
|---|---|---|
tabindexの正値乱用 |
順序崩壊・迷子 | DOM順の設計を見直し、tabindex="0"のみに |
| フォーカスリングを消す | 見失う | :focus-visibleで美しく残す |
| モーダルで背景操作可 | 二重フォーカス・混乱 | inert/aria-hidden+トラップ+Esc+復帰 |
| 矢印無効のタブ | 学習コスト↑ | ロービングTabIndex+左右/Home/End |
| ホバーだけ開閉 | キーボード不可 | ボタンで開閉、aria-expanded |
ダイアログをdisplay:noneで隠すだけ |
SRに残る/消える問題 | 開閉時に適切な属性とフォーカス遷移 |
| ショートカットだけ | 学習不能 | メニュー・ボタン・説明と重複提供 |
12. 5分スモークテスト:毎リリースの最小儀式
- Tabのみで主要機能を完遂(開く→操作→閉じる→次へ)。
- 最初のTabでスキップリンクが現れ、本文に着地。
- タブ/メニュー:左右/上下矢印で内部移動、
Home/Endが効く。 - モーダル:開く→最初の操作にフォーカス→Esc→トリガに復帰。
- フォーカスリングが常に見える(ライト/ダーク両方)。
- ショートカット:ヘルプに一覧、代替操作あり、ブラウザ予約と衝突しない。
- SR(NVDA/VoiceOver)で名前・役割・値が読み上げられる。
13. コードスニペット集(コピペOK)
13.1 フォーカス可視のトークン
:root{ --focus-color:#FF9900; --focus-width:3px; --focus-offset:3px; }
:focus-visible{ outline: var(--focus-width) solid var(--focus-color); outline-offset: var(--focus-offset); }
13.2 inertフォールバック(簡易)
function setInert(el, inert){
if('inert' in HTMLElement.prototype){ el.inert = inert; }
else { el.setAttribute('aria-hidden', String(inert)); }
}
13.3 グリッドの矢印移動(骨子)
function moveInGrid(e, cols){
const items = [...document.querySelectorAll('[role="gridcell"]')];
const i = items.indexOf(document.activeElement);
const map = { ArrowRight: i+1, ArrowLeft: i-1, ArrowDown: i+cols, ArrowUp: i-cols };
if(map[e.key] !== undefined){ items[(map[e.key]+items.length)%items.length]?.focus(); e.preventDefault(); }
}
13.4 “戻る場所”の保存と復帰
let lastFocus;
function rememberFocus(){ lastFocus = document.activeElement; }
function restoreFocus(){ lastFocus?.focus(); }
14. ケーススタディ:フォーカスの整理だけで離脱が減った話
Before
- フォーカスリングは消去。
- メガメニューはホバーで開閉、キーボードでは到達不能。
- モーダルは背景が操作可能で、Escも効かない。
After
:focus-visibleで太い輪郭を導入。- メガメニューをボタン開閉+矢印移動に刷新。
- モーダルをトラップ+Esc+復帰に改修、背景を
inert。
結果:フォーム完了率 +14%、SRユーザーからの「操作に迷う」指摘 −76%、ヘルプ参照率 −28%。
15. 組織導入:デザインシステムに“動線のルール”を刻む
- コンポーネント仕様にキーボード操作表(Tab/矢印/Home/End/Esc/Enter/Space)を必ず添付。
- フォーカストークン(色・太さ・オフセット)を全要素共通で定義。
- **“Doneの定義”**に、スキップリンク・フォーカス順・モーダルの4点セット・ロービングTabIndex・ショートカットの代替を追加。
- 自動検査+5分スモークをPRテンプレへ。リグレッションを最小化します。
16. 対象読者と導入メリット(具体)
- フロントエンドエンジニア:ロービングTabIndexやモーダル制御の定石化で実装が安定、回帰バグが激減。
- UI/UXデザイナー:フォーカス可視のデザイン言語が整い、アクセシビリティとブランド表現を両立。
- PM/ディレクター:AA準拠の受入基準が明文化され、品質ゲートが明確に。
- QA/CS:テスト観点が定型化、問い合わせの**「操作に迷う」系**が減少。
- 利用者:キーボード/支援技術/スイッチ操作でも迷わず・確実にタスク完了。
17. アクセシビリティレベルの評価(本記事の到達点)
- WCAG 2.1 AA(主な対応)
- 2.1.1 キーボード:すべての機能がキーボードで操作可能
- 2.4.3 フォーカス順序:論理順の確保
- 2.4.7 フォーカス可視:太さ・色・オフセットで明確化
- 2.4.1 ブロックの回避:スキップリンク
- 4.1.2 名前・役割・値:
aria-*で状態・関係を露出 - 1.4.11 非テキストコントラスト:フォーカスリング・境界の3:1
- 2.2.2 一時停止・停止・非表示:モーダルやメニューの開閉制御
- WCAG 2.2(推奨)
- 2.5.7 Dragging Movements:ドラッグの代替
- 2.5.8 Target Size (Minimum):操作ターゲットの最小サイズ(44×44px目安)
18. まとめ:フォーカスは“やさしい光”
- Tab順=論理順、最初のTabで本文へ。
- フォーカスリングは太く・高コントラスト、
:focus-visibleで気持ちよく。 - ロービングTabIndexで、複合UIの操作規則を統一。
- モーダルの4点セット(開く・トラップ・Esc・復帰)を必ず。
- ショートカットは表示・代替・衝突回避で“優しい高速”。
- 5分スモークとデザインシステムで、品質を継続して育てる。
フォーカスの旅路が整えば、どんな入力手段でも同じ理解と結果へ辿り着けます。あなたのUIが、静かに道しるべを灯す存在になりますように。わたしも心を込めてお手伝いしますね。
