タブ切り替えUIの実装

以下では、DOMを使って「タブ切り替えUI」をアクセシビリティ対応拡張しやすい構成で実装する方法を解説します。サンプルは**HTML/CSS/JavaScript(素のDOM API)**のみで動き、次の要件を満たします。

  • クリックによるタブ切り替え

  • キーボード操作(左右矢印/Home/End/Enter/Space)

  • WAI-ARIAロールと属性(role="tablist", role="tab", role="tabpanel" など)

  • URLハッシュによるタブの直リンク復元

  • イベント委任でスケールしやすい構造


1. マークアップ(HTML)

タブはボタン(または<a>)を使い、タブとパネルを1対1で結びます。aria-controlsid の対応が重要です。

html
<section class="tabs" id="example-tabs"> <div class="tablist" role="tablist" aria-label="サンプルのタブ"> <button role="tab" id="tab-intro" aria-controls="panel-intro" aria-selected="true" tabindex="0"> 概要 </button> <button role="tab" id="tab-api" aria-controls="panel-api" aria-selected="false" tabindex="-1"> API </button> <button role="tab" id="tab-impl" aria-controls="panel-impl" aria-selected="false" tabindex="-1"> 実装 </button> </div> <div id="panel-intro" role="tabpanel" aria-labelledby="tab-intro"> <h3>概要</h3> <p>このパネルはタブUIの概念説明です。</p> </div> <div id="panel-api" role="tabpanel" aria-labelledby="tab-api" hidden> <h3>API</h3> <p>公開メソッドやイベントなどの説明。</p> </div> <div id="panel-impl" role="tabpanel" aria-labelledby="tab-impl" hidden> <h3>実装</h3> <p>コード断片や手順を掲載。</p> </div> </section>

ポイント:

  • 初期選択タブaria-selected="true"tabindex="0" を付与。他は false-1

  • 非表示パネルは hidden(CSSでは[hidden]{display:none}相当)を使用。

  • aria-controls(タブ→パネル)と aria-labelledby(パネル→タブ)で双方向に結ぶ。


2. スタイル(CSS)

視覚的な選択状態とフォーカスリングを明確にします。

css
.tabs .tablist { display: flex; gap: .5rem; border-bottom: 1px solid #ddd; } .tabs [role="tab"] { padding: .5rem .75rem; border: none; background: none; border-bottom: 3px solid transparent; cursor: pointer; } .tabs [role="tab"][aria-selected="true"] { border-bottom-color: currentColor; font-weight: 600; } .tabs [role="tab"]:focus { outline: 2px solid; outline-offset: 2px; } .tabs [role="tabpanel"] { padding: 1rem 0; }

3. スクリプト(JS:イベント委任+ARIA更新)

  • イベント委任.tablist にだけリスナーを付与。

  • タブ選択時に

    • すべてのタブの aria-selectedfalsetabindex-1

    • すべてのパネルを hidden

    • 対象タブを true/0、対応パネルの hidden を外す

  • キーボード操作(左右/Home/End/Enter/Space)に対応。

  • URLハッシュにパネルIDを設定し、直リンク・戻る進むに追従。

html
<script> (function () { function initTabs(root) { const tablist = root.querySelector('[role="tablist"]'); const tabs = Array.from(tablist.querySelectorAll('[role="tab"]')); const panels = tabs.map(tab => document.getElementById(tab.getAttribute('aria-controls'))); function selectTab(tab, { setHash = true, focus = true } = {}) { tabs.forEach(t => { t.setAttribute('aria-selected', 'false'); t.setAttribute('tabindex', '-1'); }); panels.forEach(p => p.hidden = true); const panel = document.getElementById(tab.getAttribute('aria-controls')); tab.setAttribute('aria-selected', 'true'); tab.setAttribute('tabindex', '0'); panel.hidden = false; if (focus) tab.focus(); if (setHash) { // ハッシュをパネルIDに更新(例: #panel-api) const id = panel.id; if (id) history.replaceState(null, '', '#' + id); } } // クリック tablist.addEventListener('click', (e) => { const tab = e.target.closest('[role="tab"]'); if (!tab || !tablist.contains(tab)) return; selectTab(tab); }); // キーボード tablist.addEventListener('keydown', (e) => { const current = document.activeElement.closest('[role="tab"]'); if (!current) return; const idx = tabs.indexOf(current); let nextIdx = idx; switch (e.key) { case 'ArrowRight': nextIdx = (idx + 1) % tabs.length; e.preventDefault(); tabs[nextIdx].focus(); // roving tabindex break; case 'ArrowLeft': nextIdx = (idx - 1 + tabs.length) % tabs.length; e.preventDefault(); tabs[nextIdx].focus(); break; case 'Home': e.preventDefault(); tabs[0].focus(); break; case 'End': e.preventDefault(); tabs[tabs.length - 1].focus(); break; case 'Enter': case ' ': e.preventDefault(); selectTab(document.activeElement); break; } }); // ハッシュ直リンク対応(#panel-xxx を見て該当タブを選択) function syncFromHash({ focus = false } = {}) { const id = location.hash.slice(1); if (!id) return; const targetPanel = root.querySelector('#' + CSS.escape(id)); if (!targetPanel) return; const labelId = targetPanel.getAttribute('aria-labelledby'); const tab = labelId && root.querySelector('#' + CSS.escape(labelId)); if (tab) selectTab(tab, { setHash: false, focus }); } window.addEventListener('hashchange', () => syncFromHash({ focus: true })); // 初期化:ハッシュ優先、なければ aria-selected="true" のタブ const initiallySelected = tabs.find(t => t.getAttribute('aria-selected') === 'true') || tabs[0]; panels.forEach(p => p.hidden = true); syncFromHash(); if (root.querySelector('[role="tabpanel"][hidden]') === null) { // ハッシュで未選択だった場合にデフォルト選択 selectTab(initiallySelected, { setHash: false, focus: false }); } } document.querySelectorAll('.tabs').forEach(initTabs); })(); </script>

4. 設計の要点

  1. アクセシビリティ(A11y)

    • role と ARIA属性で関係性を明示。支援技術がタブ群であることを理解できます。

    • ロービング tabindex(選択タブのみ tabindex="0"、他は -1")でフォーカス移動を制御。

    • hidden はスクリーンリーダーにも非表示として扱われるため、非表示パネルに適切。

  2. イベント委任

    • .tablist だけにリスナーをつけることで、タブの増減に強く、DOM再構築にも対応しやすい。

  3. 状態の単一情報源

    • 真の表示状態はDOM属性aria-selectedhidden)に持たせることで、CSS・JS・アクセシビリティが同期します。

  4. URLハッシュ連携

    • #panel-...直リンク履歴操作と整合を取り、再訪時も状態復元できます。

  5. 拡張性

    • 同一ページに複数の .tabs セクションを置いても、initTabs が個別に初期化します。

    • データ属性(例:data-active-class="is-active")を導入すれば、クラス切替ベースのデザインにも即応可能。


5. よくある落とし穴と対策

  • display: none のみで隠す
    → 視覚的には良いが、スクリーンリーダー向けの状態が曖昧になりやすい。hidden と ARIA を併用する。

  • aria-selected だけ更新して tabindex を忘れる
    → キーボードフォーカス遷移がおかしくなる。必ずセットで更新。

  • タブとパネルの対応ミス
    aria-controlsaria-labelledby の ID を突合。テスト時にコンソールでチェック。

  • フォーカスリングの抑制
    → アクセシビリティ低下につながるため、:focus { outline: auto; } 相当を残す。


6. 拡張例

  • アニメーションprefers-reduced-motion を尊重しつつ、opacityheight のトランジションを付与。

  • 非同期読み込み:タブ選択時にフェッチしてパネルへ挿入(fetch + innerHTML / DOMParser)。

  • タブの無効化aria-disabled="true" を付与し、CSSで見た目とポインターイベントを制御。

  • フォーム内利用:タブ切り替えでもフォーム要素の値は維持される。送信前にバリデーション連携が可能。


7. テスト項目(チェックリスト)

  • マウスクリックで正しく切り替わるか

  • Tab/Shift+Tab でタブ間フォーカス移動、左右矢印で循環移動できるか

  • Enter/Space で選択反映されるか

  • スクリーンリーダーでタブ名と選択状態が読まれるか

  • URLに #panel-xxx を付けて直接アクセスすると該当タブが開くか

  • 複数タブセットを同一ページに置いても干渉しないか


以上の構成をベースにすれば、DOMの基本APIだけで使いやすく、拡張可能で、アクセシブルなタブ切り替えUIを実装できます。必要であれば、アニメーションやデータ取得連携、コンポーネント化(Web Components/フレームワーク移植)もこの土台から容易に行えます。

ChatGPT5 生成日:2025/09/11