こんにちは、かつコーチです。
前回はアコーディオンの開閉を実装し、複数要素の管理という考え方に触れました。
今回はその発展として、タブによるコンテンツ切り替えを実装していきます。
タブメニューをクリックすると、対応する内容だけが表示され、他の内容は隠れる、というよく見る動きです。
タブUIとは?
アコーディオンとの違い
アコーディオンは「複数の項目を同時に開ける(または単独展開)」仕組みでした。
一方タブは、必ずどれか1つだけが表示され、残りは常に非表示という点が明確に違います。
「選択肢の中から1つを選ぶ」という性質上、タブの実装では「今どれが選ばれているか」を管理する考え方が中心になります。
使われる場面
- 商品ページの「商品説明」「サイズ表」「レビュー」の切り替え
- マイページの「注文履歴」「お気に入り」「設定」といったセクション切り替え
- 料金プランの「月額」「年額」表示切り替え
見た目を変えずに、限られたスペースの中で複数の情報を出し分けたいときに向いているUIです。
基本の実装手順
手順1:HTMLでタブとパネルを対応させる
タブのボタンと、表示する中身(パネル)をそれぞれ用意し、data-* 属性で対応関係を持たせます。
<div class="tabs">
<div class="tabs__nav" role="tablist">
<button class="tabs__btn is-active" data-target="description" role="tab">商品説明</button>
<button class="tabs__btn" data-target="size" role="tab">サイズ表</button>
<button class="tabs__btn" data-target="review" role="tab">レビュー</button>
</div>
<div class="tabs__panel is-active" id="description" role="tabpanel">
<p>厳選素材を使用した、通気性の良いTシャツです。</p>
</div>
<div class="tabs__panel" id="size" role="tabpanel">
<p>S:着丈65cm / M:着丈68cm / L:着丈71cm</p>
</div>
<div class="tabs__panel" id="review" role="tabpanel">
<p>「サイズ感がちょうど良かったです」(★★★★☆)</p>
</div>
</div>
data-target にパネル側の id と同じ値を入れておくことで、「このタブをクリックしたら、どのパネルを表示すべきか」をHTML上で対応付けられます。
data-* 属性は、JavaScriptから読み取るための「HTML側に持たせる目印」として非常によく使われるので、ここで覚えておきましょう。
手順2:CSSで初期状態を作る
.tabs__nav {
display: flex;
border-bottom: 2px solid #ddd;
}
.tabs__btn {
padding: 12px 20px;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 15px;
color: #666;
margin-bottom: -2px;
}
.tabs__btn.is-active {
color: #222;
font-weight: bold;
border-bottom: 2px solid #222;
}
.tabs__panel {
display: none;
padding: 20px 0;
}
.tabs__panel.is-active {
display: block;
}
タブのUIでは、アコーディオンで使った max-height ではなく display: none / display: block の切り替えで十分です。
パネルを開閉するアニメーションが不要なタイプのUIなので、シンプルな実装で済みます。
手順3:JavaScriptでアクティブなタブを切り替える
const tabButtons = document.querySelectorAll('.tabs__btn');
const tabPanels = document.querySelectorAll('.tabs__panel');
tabButtons.forEach((button) => {
button.addEventListener('click', () => {
const targetId = button.dataset.target;
// 全ボタン・全パネルからis-activeを外す
tabButtons.forEach((btn) => btn.classList.remove('is-active'));
tabPanels.forEach((panel) => panel.classList.remove('is-active'));
// クリックされたボタンと、対応するパネルにだけis-activeを付ける
button.classList.add('is-active');
document.getElementById(targetId).classList.add('is-active');
});
});
button.dataset.target で、HTMLに書いた data-target="description" の値(description という文字列)を取得できます。
data-* 属性は、JavaScript側からは dataset というプロパティを通してアクセスする、というルールを覚えておいてください(data-target なら dataset.target、data-user-id なら dataset.userId のように、ハイフン区切りがキャメルケースに変換されます)。
つまずきやすいポイント:タブとパネルの対応がズレる
私が実際にハマった経験
私が実務でタブUIを実装したとき、パネルを1つ追加した際に、HTML側の id を変更したのにJavaScript側やCSS側の値を更新し忘れ、追加した3つ目のタブをクリックしても何も表示されない、という不具合を出してしまったことがあります。
原因を調べると、data-target="review" に対して、パネル側の id を誤って reviews(複数形)にしてしまっていました。
document.getElementById('review') が null を返し、.classList.add() を呼んだところでエラーになっていたのですが、コンソールを見るまで気づけませんでした。
❌ Before:data-target と id の値がズレている
<button class="tabs__btn" data-target="review">レビュー</button>
...
<div class="tabs__panel" id="reviews">
<p>「サイズ感がちょうど良かったです」</p>
</div>
const targetId = button.dataset.target; // "review"
document.getElementById(targetId).classList.add('is-active');
// id="review" の要素が存在しないため、getElementByIdはnullを返す
// → nullに対して.classList.addを呼び出し、TypeErrorになる
✅ After:値を完全に一致させ、念のため存在チェックも入れる
<button class="tabs__btn" data-target="review">レビュー</button>
...
<div class="tabs__panel" id="review">
<p>「サイズ感がちょうど良かったです」</p>
</div>
const targetId = button.dataset.target;
const targetPanel = document.getElementById(targetId);
if (targetPanel) {
targetPanel.classList.add('is-active');
} else {
console.warn(`パネルが見つかりません: id="${targetId}"`);
}
data-target と id の値を完全一致させるのが根本対応ですが、それに加えて if (targetPanel) で存在チェックを入れておくと、万が一値がズレてもエラーで処理が止まらず、コンソールに警告が出るだけで済みます。
タブの数が増えるほど、こうした対応関係のズレは起きやすくなるので、防御的なチェックを入れておく価値は十分にあります。
応用:URLのハッシュと連動させる
ページを再読み込みしてもタブの状態を保つ
実務では、「特定のタブを開いた状態のURLを共有したい」という要望が出ることがあります。
URLの末尾に #review のようなハッシュを付け、ページ読み込み時にそのハッシュを見てタブを切り替える実装です。
function activateTabById(targetId) {
tabButtons.forEach((btn) => btn.classList.remove('is-active'));
tabPanels.forEach((panel) => panel.classList.remove('is-active'));
const targetButton = document.querySelector(`[data-target="${targetId}"]`);
const targetPanel = document.getElementById(targetId);
if (targetButton && targetPanel) {
targetButton.classList.add('is-active');
targetPanel.classList.add('is-active');
}
}
tabButtons.forEach((button) => {
button.addEventListener('click', () => {
const targetId = button.dataset.target;
activateTabById(targetId);
history.replaceState(null, '', `#${targetId}`); // URLにハッシュを反映
});
});
// ページ読み込み時、URLにハッシュがあればそのタブを開いた状態にする
window.addEventListener('DOMContentLoaded', () => {
const hash = window.location.hash.replace('#', '');
if (hash) {
activateTabById(hash);
}
});
「切り替え処理」を activateTabById という関数にまとめておくことで、クリック時とページ読み込み時の両方から同じ処理を呼び出せるようにしています。
同じロジックをコピペで2箇所に書くと、片方だけ修正して不具合になるリスクがあるため、共通処理は関数化しておくのが安全です。
jQueryではこう書けます(参考)
$('.tabs__btn').on('click', function () {
const target = $(this).data('target');
$('.tabs__btn, .tabs__panel').removeClass('is-active');
$(this).addClass('is-active');
$('#' + target).addClass('is-active');
});
$(this).data('target') が dataset.target に相当する書き方です。
詳しい解説は別カテゴリのjQuery記事で扱います。
まとめ
この記事のポイント
- タブUIは「常に1つだけがアクティブ」という点でアコーディオンと性質が異なる
data-*属性とdatasetプロパティを使うと、HTMLとJavaScriptの対応関係を分かりやすく保てるdata-targetとidの値のズレはよくあるミスなので、存在チェックを入れておくと安全- 切り替え処理を関数化しておくと、クリック時・初期表示時など複数箇所から呼び出せて保守しやすい
次に読むべき記事
次回は、モーダルウィンドウの表示・非表示を実装します。
「特定の要素だけを表示・非表示にする」という考え方は今回までの内容の延長ですが、背景のスクロール制御など新しい注意点も出てきます。
→ 次の記事:モーダルウィンドウの表示・非表示を実装する