キーボードだけで使えるナビゲーション設計 完全ガイド:Tab一つで迷わないUIをつくる
概要サマリー(先に要点)
- すべての操作をキーボードだけで完結できることがアクセシビリティの土台
- DOM順=視覚順、フォーカスが常に見える、予測できる移動が三本柱
- 役割に合ったネイティブ要素を優先(
button
・a
・nav
・ul/li
)し、必要最小限のARIA- スキップリンク/ランドマーク/
:focus-visible
は必須セット- メニュー・タブ・モーダルなど主要コンポーネントのキー操作仕様と実装サンプル
- アンチパターン回避・手動テスト手順・運用PDCAで品質を保つ
- 想定読者と導入効果、到達目標(WCAG 2.1 AA)を明確化
1. はじめに:なぜ「キーボードだけで使える」ことが最優先なの?
マウスが使えない状況や、スクリーンリーダー・スイッチ操作・音声入力を使う場面は想像以上に多くあります。キーボード操作への最適化は、障がいの有無にかかわらず誰もが迷わず操作できるUIをもたらします。さらに、フォーカス順やナビゲーションが整うと、認知的負荷の軽減・作業効率の向上・離脱率の低下といった効果が期待できます。
本ガイドでは、AA準拠(後述)を視野に、ナビゲーションをキーボードだけで完結させる設計・実装・検証を、具体的なコードとともに丁寧に解説しますね。
2. 原則:この3つを守れば強い
- DOM順=視覚順
見えている順にTabが進めば迷いません。CSSで視覚順だけを入れ替える(order
・position
乱用など)は避け、レイアウトとDOM順を一致させます。 - フォーカスは常に見える
:focus-visible
でコントラストの高い輪郭を表示。outlineを消さないのが鉄則です。 - 予測できる移動
似たコンポーネントでは同じキー操作が効くこと(タブなら矢印、メニューなら矢印+Escなど)。慣れがそのまま操作性になります。
3. 基礎セット:スキップリンク・ランドマーク・見出し
3.1 スキップリンク(最短で主要内容へ)
<a class="skip" href="#main">本文へスキップ</a>
<header>…</header>
<nav aria-label="主なナビゲーション">…</nav>
<main id="main" tabindex="-1">…</main>
.skip {
position:absolute; left:-9999px; top:auto;
}
.skip:focus {
left:16px; top:16px; z-index:1000;
background:#fff; color:#000; padding:.5em .75em;
outline:3px solid #ff9900;
}
main
にtabindex="-1"
を与えると、スキップ時に確実にフォーカスを当てられます。
3.2 ランドマークと見出しで「地図」をつくる
header
/nav
/main
/aside
/footer
を適切に使い、必要に応じてaria-label
。- 見出し階層(
h1
→h2
→h3
…)を正しく。スクリーンリーダーは見出しジャンプでページを素早く探索します。
3.3 :focus-visible
で「今どこ?」を常に明示
:focus-visible {
outline:3px solid #ff9900;
outline-offset:2px;
border-radius:4px;
}
- マウス操作では表示されにくく、キーボード操作で確実に見えるのが
focus-visible
の利点です。
4. tabindex
・役割・ラベル:必要最小限で堅牢に
tabindex="0"
:本来フォーカス不可能な要素をTab対象にする時のみ。乱用禁止。tabindex="-1"
:プログラムからフォーカスを当てたい時(スキップリンク先、モーダルのヘッダなど)。tabindex
正の値は使用しない:Tab順が壊れ、保守不能になります。- ラベルは可視テキストと一致(「ボタンに書いてある文字=アクセシブルネーム」)。
- ネイティブ要素優先:リンクは
<a href>
, ボタンは<button>
。<div role="button">
は最後の手段。
5. コンポーネント別:期待されるキー操作と実装
5.1 グローバルナビ(水平メニュー/ドロップダウン)
期待される操作
- Tab/Shift+Tab:親メニュー間を移動
- Enter/Space:サブメニューの開閉
- ↓↑(開いた状態):サブメニュー内を移動
- Esc:サブメニューを閉じて親へフォーカス
推奨構造(サイトナビはrole="menu"
を付けないのが原則。文書用のul/li
+button
で十分)
<nav aria-label="主なナビゲーション">
<ul class="gnav">
<li>
<button aria-expanded="false" aria-controls="products-sub">製品</button>
<ul id="products-sub" hidden>
<li><a href="/products/a">製品A</a></li>
<li><a href="/products/b">製品B</a></li>
</ul>
</li>
<li><a href="/pricing">価格</a></li>
<li><a href="/support">サポート</a></li>
</ul>
</nav>
const toggles = document.querySelectorAll('nav button[aria-controls]');
toggles.forEach(btn => {
const sub = document.getElementById(btn.getAttribute('aria-controls'));
btn.addEventListener('click', () => {
const open = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', String(!open));
sub.hidden = open;
if (!open) sub.querySelector('a')?.focus();
});
btn.addEventListener('keydown', e => {
if (e.key === 'Escape') { btn.setAttribute('aria-expanded','false'); sub.hidden = true; btn.focus(); }
});
});
- ポイント:
- 親は
button
(リンクではない)。 - サブメニューは表示時のみTab対象(
hidden
で除外)。 - Escで閉じる。開いたら最初の項目にフォーカス。
- 親は
5.2 ハンバーガーメニュー(モバイル)
期待される操作
- Enter/Spaceで開閉、Escで閉じる。
- 開いたらメニュー全体にフォーカストラップ、閉じたら開く前に居た場所へ戻す。
<button id="menu-toggle" aria-controls="drawer" aria-expanded="false" aria-label="メニューを開く">☰</button>
<aside id="drawer" role="dialog" aria-modal="true" hidden>
<nav aria-label="モバイルメニュー">
<a href="/home">ホーム</a>
<a href="/news">お知らせ</a>
<a href="/contact">お問い合わせ</a>
<button id="close">閉じる</button>
</nav>
</aside>
const toggle = document.getElementById('menu-toggle');
const drawer = document.getElementById('drawer');
const closeBtn = document.getElementById('close');
let lastFocus;
function openDrawer() {
lastFocus = document.activeElement;
drawer.hidden = false;
toggle.setAttribute('aria-expanded','true');
drawer.querySelector('a,button')?.focus();
document.addEventListener('keydown', trap);
}
function closeDrawer() {
drawer.hidden = true;
toggle.setAttribute('aria-expanded','false');
lastFocus?.focus();
document.removeEventListener('keydown', trap);
}
function trap(e){
if(e.key==='Escape'){ closeDrawer(); return; }
if(e.key!=='Tab') return;
const focusables = drawer.querySelectorAll('a, button, [tabindex="0"]');
const first = focusables[0], last = focusables[focusables.length-1];
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
}
toggle.addEventListener('click', ()=>drawer.hidden?openDrawer():closeDrawer());
closeBtn.addEventListener('click', closeDrawer);
5.3 タブ(roving tabindex
)
期待される操作
- Tab:選択中のタブにのみ到達
- ←→(水平)/↑↓(垂直):タブ移動
- Enter/Space:選択・パネル切替
<div role="tablist" aria-label="設定">
<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"]')];
function activate(i, focus=true){
tabs.forEach((t,idx)=>{
const selected = idx===i;
t.setAttribute('aria-selected', String(selected));
t.tabIndex = selected?0:-1;
const panel = document.getElementById(t.getAttribute('aria-controls'));
panel.hidden = !selected;
});
if(focus) tabs[i].focus();
}
tabs.forEach((tab,i)=>{
tab.addEventListener('keydown', e=>{
const keys = {ArrowRight:1, ArrowLeft:-1, ArrowDown:1, ArrowUp:-1};
if (e.key in keys){ e.preventDefault();
let next = (i + keys[e.key] + tabs.length) % tabs.length; activate(next);
} else if (e.key==='Home'){ e.preventDefault(); activate(0); }
else if (e.key==='End'){ e.preventDefault(); activate(tabs.length-1); }
});
tab.addEventListener('click', ()=>activate(i,false));
});
- ポイント:Tabは選択中タブのみに当たり、他は矢印キーで移動。読み上げも「選択状態」を伝えます。
5.4 アコーディオン(詳細の開閉)
<h3>
<button aria-expanded="false" aria-controls="a1" id="h1">配送について</button>
</h3>
<div id="a1" role="region" aria-labelledby="h1" hidden>
<p>…説明…</p>
</div>
document.querySelectorAll('h3 > button').forEach(btn=>{
const panel = document.getElementById(btn.getAttribute('aria-controls'));
btn.addEventListener('click', ()=>{
const open = btn.getAttribute('aria-expanded')==='true';
btn.setAttribute('aria-expanded', String(!open));
panel.hidden = open;
});
});
- ポイント:見出し+
button
でどこが開閉トリガか明確。開閉状態はaria-expanded
で通知。
5.5 サイドバー(ツリー/階層ナビ)
小規模なら単純なul/li
で十分。複雑な階層では親はbutton
で開閉、子はa
。
期待操作:矢印キーで行移動、Enter/Spaceで開閉またはリンク遷移、Home/Endで先頭末尾。
5.6 検索オートコンプリート
期待操作
- 入力 → ↓で候補に入る、↑↓で移動、Enterで決定、Escで候補を閉じる。
実装のヒント role="listbox"
/role="option"
またはaria-autocomplete="list"
の適切な組合わせ。- 候補が開いたら**
aria-expanded="true"
、現在ハイライトはaria-activedescendant
**で伝える。
6. フォーカス管理:ページ遷移・モーダル・トースト
6.1 SPAのルーティング
- ルート変更後、
<h1>
または<main>
の先頭にプログラムでフォーカス。 - ページ先頭に「更新通知用live領域」を置き、変化を音声でも知らせると親切です。
<div aria-live="polite" aria-atomic="true" class="sr-only"></div>
6.2 モーダルダイアログ
role="dialog"
+aria-modal="true"
、最初にフォーカス可能要素へ移動。- フォーカストラップ(Tab循環)とEscで閉じるは必須。
- 背景はクリックで閉じても、閉じた後はトリガに戻す。
6.3 トースト通知
role="status"
(控えめ)やrole="alert"
(緊急)で読み上げ。- 自動で消す場合も**「閉じる」ボタン**を用意し、キーボードで操作可能に。
7. 見やすいフォーカスインジケーターのデザイン
:root { --focus: #1a73e8; }
a:focus-visible, button:focus-visible, [role="tab"]:focus-visible {
outline: 3px solid var(--focus);
outline-offset: 2px;
box-shadow: 0 0 0 3px color-mix(in srgb, var(--focus) 30%, transparent);
}
@media (prefers-contrast: more){
:focus-visible { outline-width: 4px; }
}
- コントラスト比を意識(背景に対して十分濃い色)。
- 太く・オフセットを付けて被写界のノイズに埋もれないように。
8. よくあるアンチパターンと回避策
- outline: none; でフォーカスが消える →
:focus-visible
で上書きして見やすく。 tabindex
正の値(例:tabindex="9999"
)→ 資産化不能。0か-1のみで設計。<div role="button">
+onclick
→ キー対応漏れがち。<button>
を使用。- リンクに
href="#"
→ 目的地が不明・タブ順も混乱。本当にリンクか再考し、ボタンと役割を分ける。 - DOM順と視覚順の乖離 → CSS再考。順序はHTMLで決める。
- 開いたサブメニューがTabで離脱できない → Escと閉じる処理を実装。
- SPAで初期フォーカスを当てない → 遷移後は**
main
やh1
へフォーカス**。
9. 手動テスト手順(現場でそのまま使える)
- Tab巡回:ヘッダー→グロナビ→メイン→フッターへ論理順で移動できるか。
- 逆順(Shift+Tab):戻る動作でも破綻がないか。
- スキップリンク:最初のTabで現れ、押すと本文先頭へ。
- メニュー:Enter/Spaceで開閉、矢印で項目移動、Escで閉じる。
- タブ:矢印移動、Enter/Spaceで切替、非表示パネルはTab対象外。
- モバイルドロワー:開いたらフォーカスが内側に留まり、Esc/閉じるで元の場所へ戻る。
- モーダル:開閉・トラップ・Esc・背景クリック・フォーカス復帰を全確認。
- 読み上げ(任意):スクリーンリーダーで見出し/ランドマーク/リンク数が期待通りか。
- 高コントラスト:OSやブラウザの高コントラストでフォーカスが見えるか。
10. 運用:設計→実装→レビュー→回帰テストのPDCA
- 設計:デザインシステムにキー操作仕様を明文化(コンポーネントごとに表に)。
- 実装:ネイティブ優先・最小ARIA。E2EテストにTab巡回を組み込む。
- レビュー:PRテンプレに「キーボード操作確認済み」チェック項目を追加。
- 回帰:UI改修のたびに手動テスト8項目を実施。スプリントレビューで結果共有。
11. サンプル:チェック可能な簡易ナビ(まとめ版)
<a class="skip" href="#main">本文へスキップ</a>
<header>
<h1>サイト名</h1>
<nav aria-label="主なナビゲーション">
<ul class="gnav">
<li>
<button aria-expanded="false" aria-controls="sub1">サービス</button>
<ul id="sub1" hidden>
<li><a href="/consulting">コンサルティング</a></li>
<li><a href="/training">トレーニング</a></li>
</ul>
</li>
<li><a href="/about">私たちについて</a></li>
<li><a href="/contact">お問い合わせ</a></li>
</ul>
</nav>
</header>
<main id="main" tabindex="-1">
<h2>見出しタイトル</h2>
<p>本文テキスト…</p>
<div role="tablist" aria-label="コンテンツ切替">
<button role="tab" aria-selected="true" aria-controls="pA" id="tA" tabindex="0">概要</button>
<button role="tab" aria-selected="false" aria-controls="pB" id="tB" tabindex="-1">詳細</button>
</div>
<section id="pA" role="tabpanel" aria-labelledby="tA">…概要コンテンツ…</section>
<section id="pB" role="tabpanel" aria-labelledby="tB" hidden>…詳細コンテンツ…</section>
</main>
<footer>© Example</footer>
(前掲のJavaScript・CSSを併用)
12. 誰が恩恵を受ける?導入効果(具体)
- フロントエンドエンジニア:コンポーネント単位でキー操作仕様が定義されるため、実装の迷いが減り、レビューも迅速に。Tab順の事故が減って回帰不具合が激減。
- UI/UXデザイナー:フォーカス状態のデザインを最初から定義することで、視覚的整合性と使いやすさが両立。ユーザビリティテストで学習コスト低下を確認しやすい。
- QA/アクセシビリティ担当:手動テスト手順(第9章)をそのままシナリオ化でき、スプリントごとに同等品質を担保。
- コンテンツ編集者・PM:ナビゲーションが迷わないため、滞在時間の伸長・離脱率低下が期待でき、KPI改善に直結。
- 支援技術ユーザー(画面リーダー・スイッチ等):一貫したキー操作で快適に移動でき、情報探索が圧倒的にスムーズに。
- 一時的制約のあるユーザー(トラックパッドが使いづらい・腕を痛めている等):キーボードだけで全機能にアクセスできる安心感。
13. アクセシビリティレベルと適合の目安(WCAG 2.1 AA)
- 2.1.1 Keyboard:すべての機能がキーボードで操作可能。
- 2.4.3 Focus Order:フォーカス移動が意味的順序を保つ。
- 2.4.7 Focus Visible:キーボードフォーカスが見える。
- 1.3.1 Info and Relationships:見出し・リスト・ランドマークで関係性を示す。
- 2.5.3 Label in Name:ボタンの可視テキスト=アクセシブルネーム。
- 3.2.1/3.2.2:フォーカスや入力で予期せぬ変更を起こさない(勝手に遷移しない)。
本ガイドの実装とテストを満たせば、AA適合の強固なベースができあがります。
14. まとめ:Tab一つで迷わない体験は、思いやりの設計から
- DOM順・フォーカス可視・予測可能性の原則を守る。
- スキップリンク/ランドマーク/見出しで情報の地図を用意。
- メニュー・タブ・モーダルは期待されるキー操作を実装し、最小ARIAで堅牢に。
- アンチパターンを避け、手動テスト8項目を開発フローに常設。
- 運用PDCAで継続的に磨き、WCAG 2.1 AAへ。
キーボードだけで迷わず操作できることは、すべての人の安心と自信につながります。あなたのサイトが最初のTabから最後のEnterまで心地よく流れる体験になりますように、わたしも心を込めて応援しています。
付録A:実務チェックリスト(抜粋)
- [ ] 最初のTabで「本文へスキップ」が表示/機能する
- [ ] ヘッダー→ナビ→メイン→フッターの順に論理的に移動
- [ ] すべてのフォーカスに明確なインジケーター
- [ ] ドロップダウンはEnter/Spaceで開閉、Escで閉じる、開時に内部Tab
- [ ] タブはroving tabindex・矢印移動・非表示パネルはTab対象外
- [ ] モーダルはトラップ・Esc・復帰が機能
- [ ]
tabindex
は0/-1のみ。正値は不使用 - [ ]
outline
は消さない(またはfocus-visible
で上書き) - [ ] 役割に合ったネイティブ要素を使用(
<a>
/<button>
)
対象読者(具体):
- フロントエンドエンジニア:コンポーネント設計・実装・回帰テストの責任を担う方
- UI/UXデザイナー:フォーカス状態やキーボードフローを含めた仕様を定義する方
- QA/アクセシビリティ担当:手動テストを運用し、適合水準を管理する方
- Webディレクター/PM:開発プロセスにAA準拠を組み込み、リスクを最小化したい方