タブ切り替えUIはWebサイトで頻繁に使われるコンポーネントですが、「見た目だけ動く」実装と「アクセシブルで使いやすい」実装には大きな差があります。スクリーンリーダー対応・キーボード操作・URLの共有可能性まで考慮した実装が実務では求められます。
この記事ではdata属性を使ったシンプルな基本実装から、WAI-ARIAによるアクセシビリティ対応、キーボードナビゲーション、URLハッシュ連動、フェードアニメーション、動的タブ追加まで体系的に解説します。
基本実装:data属性で紐付ける
onclick 属性にインラインJavaScriptを書く方法はHTMLとJSが混在して保守しにくくなります。data-tab 属性でタブとパネルを紐付けるパターンが現代的な書き方です。
<div class="tabs">
<!-- タブボタン -->
<div class="tab-list">
<button class="tab-btn active" data-tab="panel-1">タブ1</button>
<button class="tab-btn" data-tab="panel-2">タブ2</button>
<button class="tab-btn" data-tab="panel-3">タブ3</button>
</div>
<!-- タブパネル -->
<div id="panel-1" class="tab-panel active">パネル1のコンテンツ</div>
<div id="panel-2" class="tab-panel">パネル2のコンテンツ</div>
<div id="panel-3" class="tab-panel">パネル3のコンテンツ</div>
</div>
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.tab-btn {
padding: 8px 20px;
border: 1px solid #ccc;
background: #f5f5f5;
cursor: pointer;
border-bottom: none;
}
.tab-btn.active {
background: #fff;
border-bottom: 2px solid #0284c7;
color: #0284c7;
font-weight: bold;
}
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
const targetId = btn.dataset.tab;
// すべてのボタン・パネルから active を外す
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
// クリックされたボタンと対応パネルに active を付ける
btn.classList.add('active');
document.getElementById(targetId).classList.add('active');
});
});
- HTMLとJavaScriptが分離できる(
onclickインライン不要) - タブの数が増えても JavaScript を修正しなくてよい
- セレクターで一括処理できる
WAI-ARIA でアクセシビリティ対応
スクリーンリーダーが「これはタブUIである」と認識するには、WAI-ARIAのロール(role="tablist"・role="tab"・role="tabpanel")と状態属性(aria-selected・aria-controls・aria-labelledby)を適切に設定する必要があります。
<div class="tabs">
<div role="tablist" aria-label="コンテンツタブ" class="tab-list">
<button
role="tab"
aria-selected="true"
aria-controls="panel-1"
id="tab-1"
class="tab-btn"
tabindex="0"
>タブ1</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-2"
id="tab-2"
class="tab-btn"
tabindex="-1"
>タブ2</button>
<button
role="tab"
aria-selected="false"
aria-controls="panel-3"
id="tab-3"
class="tab-btn"
tabindex="-1"
>タブ3</button>
</div>
<div
role="tabpanel"
id="panel-1"
aria-labelledby="tab-1"
class="tab-panel"
>パネル1のコンテンツ</div>
<div
role="tabpanel"
id="panel-2"
aria-labelledby="tab-2"
class="tab-panel"
hidden
>パネル2のコンテンツ</div>
<div
role="tabpanel"
id="panel-3"
aria-labelledby="tab-3"
class="tab-panel"
hidden
>パネル3のコンテンツ</div>
</div>
class TabComponent {
#tabs;
#panels;
constructor(container) {
this.#tabs = Array.from(container.querySelectorAll('[role="tab"]'));
this.#panels = Array.from(container.querySelectorAll('[role="tabpanel"]'));
this.#tabs.forEach(tab => {
tab.addEventListener('click', () => this.#activate(tab));
tab.addEventListener('keydown', (e) => this.#onKeyDown(e));
});
}
/** タブをアクティブにする */
#activate(targetTab) {
this.#tabs.forEach(tab => {
const isTarget = tab === targetTab;
tab.setAttribute('aria-selected', isTarget ? 'true' : 'false');
tab.tabIndex = isTarget ? 0 : -1;
});
this.#panels.forEach(panel => {
const isTarget = panel.id === targetTab.getAttribute('aria-controls');
panel.hidden = !isTarget;
});
targetTab.focus();
}
/** 矢印キーでタブを移動 */
#onKeyDown(e) {
const current = this.#tabs.indexOf(e.currentTarget);
let next = current;
if (e.key === 'ArrowRight') {
next = (current + 1) % this.#tabs.length;
} else if (e.key === 'ArrowLeft') {
next = (current - 1 + this.#tabs.length) % this.#tabs.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = this.#tabs.length - 1;
} else {
return;
}
e.preventDefault();
this.#activate(this.#tabs[next]);
}
}
// 使用例
document.querySelectorAll('.tabs').forEach(container => {
new TabComponent(container);
});
role="tablist":タブのリスト全体role="tab":個々のタブボタン。aria-selectedでアクティブ状態を示すrole="tabpanel":タブに対応するパネルaria-controls:タブが制御するパネルのIDaria-labelledby:パネルにラベルを付けるタブのID- 非アクティブタブは
tabindex="-1"、アクティブタブはtabindex="0" - 非表示パネルは
display:noneではなくhidden属性推奨
URLハッシュと連動させる
URLのハッシュ(#panel-2)とタブを連動させると、特定のタブが開いた状態のURLを共有できるようになります。ページリロード後も同じタブが表示されます。
class TabComponentWithHash extends TabComponent {
constructor(container) {
super(container);
// ページ読み込み時にハッシュに対応するタブを開く
this._applyHash();
// ブラウザの「戻る/進む」でハッシュが変わったとき
window.addEventListener('hashchange', () => this._applyHash());
}
#activate(targetTab) {
super.#activate(targetTab); // 親クラスの処理
// ハッシュをURLに反映
const panelId = targetTab.getAttribute('aria-controls');
history.replaceState(null, '', `#${panelId}`);
}
_applyHash() {
const hash = location.hash.slice(1); // # を除去
if (!hash) return;
const targetTab = this._tabs.find(
t => t.getAttribute('aria-controls') === hash
);
if (targetTab) this.#activate(targetTab);
}
}
history.replaceState を使う(ハッシュを変えずに履歴を更新)か、scroll-margin-top を調整します。フェードアニメーションを付ける
hidden 属性の代わりに CSS トランジションを使って、タブ切り替え時にフェードイン効果を加えます。
.tab-panel {
display: none;
opacity: 0;
transition: opacity 0.2s ease;
}
/* active になったとき表示 + フェードイン */
.tab-panel.active {
display: block;
animation: fadeIn 0.2s ease forwards;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
function switchTabWithFade(clickedBtn, allBtns, allPanels) {
const targetId = clickedBtn.dataset.tab;
allBtns.forEach(b => {
b.classList.remove('active');
b.setAttribute('aria-selected', 'false');
b.tabIndex = -1;
});
allPanels.forEach(p => p.classList.remove('active'));
clickedBtn.classList.add('active');
clickedBtn.setAttribute('aria-selected', 'true');
clickedBtn.tabIndex = 0;
const targetPanel = document.getElementById(targetId);
targetPanel.classList.add('active');
}
@media (prefers-reduced-motion: reduce) { .tab-panel { animation: none; } } を追加しておきましょう。動的にタブを追加・削除する
管理画面や設定UIで、ユーザー操作によってタブを追加・削除する実装です。
class DynamicTabs {
#tabList;
#container;
#counter = 0;
constructor(container) {
this.#container = container;
this.#tabList = container.querySelector('[role="tablist"]');
}
/** タブを追加する */
addTab(label, content) {
this.#counter++;
const id = `dynamic-panel-${this.#counter}`;
const tabId = `dynamic-tab-${this.#counter}`;
// タブボタンを作成
const btn = document.createElement('button');
btn.role = 'tab';
btn.id = tabId;
btn.setAttribute('aria-controls', id);
btn.setAttribute('aria-selected', 'false');
btn.tabIndex = -1;
btn.className = 'tab-btn';
btn.textContent = label;
// 閉じるボタン(削除用)
const closeBtn = document.createElement('span');
closeBtn.textContent = ' ×';
closeBtn.style.cursor = 'pointer';
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.removeTab(id);
});
btn.appendChild(closeBtn);
// パネルを作成
const panel = document.createElement('div');
panel.role = 'tabpanel';
panel.id = id;
panel.setAttribute('aria-labelledby', tabId);
panel.className = 'tab-panel';
panel.hidden = true;
panel.innerHTML = content;
this.#tabList.appendChild(btn);
this.#container.appendChild(panel);
btn.addEventListener('click', () => this.activateTab(id));
// 追加後すぐにアクティブにする
this.activateTab(id);
}
/** タブを削除する */
removeTab(panelId) {
const tab = this.#container.querySelector(`[aria-controls="${panelId}"]`);
const panel = document.getElementById(panelId);
const wasActive = tab?.getAttribute('aria-selected') === 'true';
tab?.remove();
panel?.remove();
// 削除したタブがアクティブだった場合、最初のタブをアクティブにする
if (wasActive) {
const firstTab = this.#container.querySelector('[role="tab"]');
if (firstTab) this.activateTab(firstTab.getAttribute('aria-controls'));
}
}
/** 指定パネルIDのタブをアクティブにする */
activateTab(panelId) {
this.#container.querySelectorAll('[role="tab"]').forEach(t => {
const isTarget = t.getAttribute('aria-controls') === panelId;
t.setAttribute('aria-selected', isTarget ? 'true' : 'false');
t.tabIndex = isTarget ? 0 : -1;
t.classList.toggle('active', isTarget);
});
this.#container.querySelectorAll('[role="tabpanel"]').forEach(p => {
p.hidden = p.id !== panelId;
p.classList.toggle('active', p.id === panelId);
});
}
}
// 使用例
const dynTabs = new DynamicTabs(document.querySelector('.tabs'));
dynTabs.addTab('新しいタブ', '<p>動的に追加されたコンテンツ</p>');
ページ内に複数のタブを共存させる
1ページに複数のタブコンポーネントがある場合、セレクター document.querySelectorAll で全体を取得してしまうと互いに干渉します。コンテナ要素を起点にすることで独立させられます。
// TabComponent クラスはコンテナを受け取る設計なので、
// 各コンテナに対してインスタンスを作るだけで独立して動作する
document.querySelectorAll('.tabs').forEach(container => {
new TabComponent(container); // それぞれ独立したインスタンス
});
// ページ内に .tabs が3つあれば、3つの独立したタブUIが動作する
よくある質問(FAQ)
hidden 属性はHTMLの意味論的な「このコンテンツは現在関係ない」を表します。スクリーンリーダーも hidden の要素は読み上げません。WAI-ARIAのタブパターンでは hidden 属性が推奨されています。display:none はCSSで制御するため、アニメーションを付けたいときに使います。min-height を指定するか、すべてのパネルを position: absolute で重ねてコンテナの高さを固定する方法があります。または要素の高さを揃える方法でJavaScriptを使ってコンテナ高さを最大値に固定する方法も有効です。ArrowRight・ArrowLeft キーでタブを移動し、Home・End で最初・最後に移動します。Tab キーはタブリストを抜けてパネルに移動します。本記事の TabComponent クラスがこのキーボード操作を実装しています。fetch() を呼んでコンテンツを取得します。「一度読み込んだら再取得しない」ようにフラグ(data-loaded="true")を付けておくと効率的です。まとめ
タブ切り替えUIの実装を段階別に整理します。
| 要件 | 実装 |
|---|---|
| 基本的なタブ切り替え | data-tab 属性 + classList.toggle |
| スクリーンリーダー対応 | role="tablist/tab/tabpanel" + aria-selected + hidden |
| キーボードナビゲーション | ArrowRight/Left/Home/End の keydown 処理 |
| URLで状態を共有 | history.replaceState + hashchange イベント |
| 切り替えアニメーション | CSS @keyframes fadeIn + prefers-reduced-motion 対応 |
| 動的なタブ追加・削除 | DOM生成 + DynamicTabs クラス |
| 複数タブの共存 | コンテナ要素を起点にクラスをインスタンス化 |
タブ切り替え後に高さを再計算したい場合は要素の高さを揃える方法(ResizeObserver対応)も参照してください。