【JavaScript】タブによるコンテンツ切り替えを実装する

javascript-icon JavaScript

こんにちは、かつコーチです。

前回はアコーディオンの開閉を実装し、複数要素の管理という考え方に触れました。

今回はその発展として、タブによるコンテンツ切り替えを実装していきます。

タブメニューをクリックすると、対応する内容だけが表示され、他の内容は隠れる、というよく見る動きです。

タブ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.targetdata-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-targetid の値を完全一致させるのが根本対応ですが、それに加えて 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-targetid の値のズレはよくあるミスなので、存在チェックを入れておくと安全
  • 切り替え処理を関数化しておくと、クリック時・初期表示時など複数箇所から呼び出せて保守しやすい

次に読むべき記事

次回は、モーダルウィンドウの表示・非表示を実装します。

「特定の要素だけを表示・非表示にする」という考え方は今回までの内容の延長ですが、背景のスクロール制御など新しい注意点も出てきます。

→ 次の記事:モーダルウィンドウの表示・非表示を実装する

タイトルとURLをコピーしました