JavaScript

JavaScriptでタブメニューを自作する|jQueryなし・2パターンの実装例付きで解説

みなと

タブメニューは、Webサイトでよく見かけるUIのひとつです。料金プランの比較、商品スペックの切り替え、FAQのカテゴリ分け、採用ページの職種別情報など、限られたスペースに複数のコンテンツをまとめたいときに活躍します。

「タブを切り替えるだけなら、それほど難しくないのでは?」と思うかもしれません。基本的な動きを作るだけなら、確かにそれほど手間はかかりません。

ただ、実務で使えるレベルを目指すと、意外と考慮するポイントが増えてきます。たとえば…

  • デザインカンプで指定されたタブのデザインを忠実に再現したい(レスポンシブ対応も含めて)
  • ページ内に複数のタブメニューを設置したい
  • パネルの高さが変わるときに、ガクッとならず自然に切り替わってほしい
  • パネルのコンテンツをフェードで切り替えたい
  • 下にスクロールして読んでいるときに下部タブを押したら、上部タブのところに戻ってほしい
  • 外部ページから特定のタブを開いた状態でリンクしたい

本記事では、こういった実務上の要件を満たした「現場でそのまま使えるレベル」のタブメニューを、jQueryなし・Vanilla JS(素のJavaScript)のみで解説します。

みなと
みなと

HTMLとCSSだけでも基本的なタブは作れますが、JavaScriptで自前実装できると、デザインの要件や動作の細かな調整に自由に対応できます。一度しっかり理解しておくと、さまざまな場面で応用が効きますよ!

今回は以下の2パターンを紹介します。

  • デモ01:シンプルなタブ切り替え(複数設置対応)
  • デモ02:上下タブ連動・外部リンク対応

デモ01:シンプルなタブ切り替え

完成デモの紹介

まずは完成デモをご確認ください。

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

フォント選定のイメージ

TOPIC 01

Webサイトの印象を決める
フォント選定の基本

フォント選びはWebサイトの第一印象を大きく左右します。日本語フォントは文字数が多くファイルサイズが大きくなりやすいため、サブセット化やGoogle Fontsの活用が表示速度対策として有効です。

Webフォントの種類

WOFF2形式が現在の主流。圧縮率が高くほぼ全ブラウザで対応しており、読み込みを速くできます。

フォントの数は絞る

1ページで使うフォントは2〜3種類が目安。増やしすぎると読み込みが遅くなり統一感も失われます。

サブセット化とは

使う文字だけを抽出してファイルを軽量化する手法。日本語サイトでは特に効果的です。

font-displayの設定

font-display: swap を指定するとフォント読み込み中も代替フォントで文字が表示され、UXが向上します。

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

レイアウト設計のイメージ

TOPIC 02

CSSだけで組む
レスポンシブレイアウト設計

FlexboxとCSS Gridを使い分けることで、複雑なレイアウトもシンプルなコードで実現できます。メディアクエリに頼りすぎず、コンテナクエリやclamp()を活用した流動的な設計が近年のトレンドです。

FlexboxとGridの使い分け

1方向の並びはFlexbox、2次元の格子状レイアウトはGridが適しています。

コンテナクエリ

親要素の幅に応じてスタイルを切り替える仕組み。コンポーネント単位での柔軟な設計が可能です。

clamp()の活用

最小値・推奨値・最大値を一度に指定できる関数。フォントサイズや余白の流動的な設定に便利です。

モバイルファースト

小さい画面のスタイルをベースに書き、min-widthで大きい画面向けに上書きする設計手法です。

  • グリッドシステムを自作するよりCSS Gridのauto-fill/auto-fitを活用する方がコードが短い
  • 余白はpxではなくremやemで統一すると、フォントサイズ変更時に崩れにくくなる
  • デザインカンプがなくてもFigmaでおおまかなワイヤーを引いてから実装するとミスが減る

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

画像最適化のイメージ

TOPIC 03

表示速度に直結する
画像最適化の実践

画像はページの読み込みサイズの大半を占めることが多く、最適化の効果が最も出やすい分野です。WebP形式への変換やlazy loadingの活用で、Core Web Vitalsのスコア改善につながります。

WebP形式

PNGやJPEGより高圧縮でありながら画質を保てる次世代フォーマット。主要ブラウザはほぼ対応済みです。

lazy loading

imgタグにloading=”lazy”を付けるだけで、画面外の画像を後から読み込む遅延ロードが実現できます。

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

パフォーマンスのイメージ

TOPIC 04

スコアだけじゃない
体感速度を高める
Web最適化

Lighthouseのスコアを上げることも重要ですが、ユーザーが「速い」と感じる体感速度の向上も同様に大切です。スケルトンスクリーンやプログレッシブレンダリングなど、体感UXを改善する手法も押さえておきましょう。

Core Web Vitals

LCP・INP・CLSの3指標をGoogleが評価。SEOにも影響するため、定期的な計測と改善が重要です。

キャッシュ制御

Cache-Controlヘッダーを適切に設定することで、リピーターの読み込みを大幅に短縮できます。

JavaScriptの遅延読み込み

defer・async属性でHTMLのパース完了後にJSを実行させ、初期描画のブロックを防ぎます。

CDNの活用

静的ファイルをCDNで配信することで、世界中のユーザーへの応答時間を均一に短縮できます。

  • PageSpeed InsightsとChrome DevToolsを組み合わせてボトルネックを特定するのが改善の近道
  • フォントの読み込みにrel=”preload”を使うと、FCP(最初のコンテンツ描画)を早めやすい

タブをクリックすると、パネルがフェードしながら切り替わります。パネルごとに高さが異なる場合も、自然なアニメーションで対応しています。

コード全体

HTML

<!-- タブ全体のラッパー。data-tab-group でグループを識別する -->
<div class="tabs" data-tab-group="demo01">

  <!-- タブリスト。data-tab にパネルのIDを指定する -->
  <ul class="tab-list">
    <li><button type="button" data-tab="font">フォント<br class="only-sp">選定</button></li>
    <li><button type="button" data-tab="layout">レイアウト<br class="only-sp">設計</button></li>
    <li><button type="button" data-tab="image">画像<br class="only-sp">最適化</button></li>
    <li><button type="button" data-tab="performance">パフォー<br class="only-sp">マンス</button></li>
  </ul>

  <!-- パネルの外枠。高さのアニメーションはこの要素に対して行う -->
  <div class="tab-panels-outer">

    <!-- data-panel はタブの data-tab と対応させる -->
    <div class="tab-panel" data-panel="font">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

    <div class="tab-panel" data-panel="layout">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

    <div class="tab-panel" data-panel="image">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

    <div class="tab-panel" data-panel="performance">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

  </div><!-- /.tab-panels-outer -->

</div><!-- /.tabs -->

CSS

/* ブレイクポイント:768px以上をPC、767px以下をSPとして扱う */

/* ===== タブリスト ===== */
.tab-list {
  display: flex;
}

@media (min-width: 768px) {
  .tab-list {
    gap: 10px;
  }
}

@media (max-width: 767px) {
  .tab-list {
    gap: 5px;
  }
}

/* 各タブを均等幅に並べる */
.tab-list li {
  flex: 1;
}

/* タブボタンの共通スタイル */
.tab-list button {
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
  width: 100%;
  box-sizing: border-box;
  border: none;
  border-top-right-radius: 5px;
  border-top-left-radius: 5px;
  background: #f8f8f8;
  font: inherit;
  font-weight: 700;
  text-align: center;
  cursor: pointer;
  /* 背景色・文字色の切り替えをなめらかにする */
  transition: background-color 500ms cubic-bezier(.215, .61, .355, 1), color 500ms cubic-bezier(.215, .61, .355, 1);
}

@media (min-width: 768px) {
  .tab-list button {
    height: 80px;
    padding: 0 0 18px;
    font-size: 18px;
    line-height: 1.5;
  }
}

@media (max-width: 767px) {
  .tab-list button {
    height: 70px;
    padding: 0 0 15px;
    font-size: 13px;
    line-height: 1.4;
  }
}

/* 矢印アイコン(::before 擬似要素で描画)、下向き(↓) */
.tab-list button::before {
  content: "";
  display: block;
  position: absolute;
  left: 50%;
  box-sizing: border-box;
  transform: rotate(45deg);
  transition: transform 300ms cubic-bezier(.215, .61, .355, 1);
}

@media (min-width: 768px) {
  .tab-list button::before {
    bottom: 15px;
    width: 11px;
    height: 11px;
    margin-left: -5.5px;
    border-right: 3px solid #1a6bbf;
    border-bottom: 3px solid #1a6bbf;
  }
}

@media (max-width: 767px) {
  .tab-list button::before {
    bottom: 12px;
    width: 8px;
    height: 8px;
    margin-left: -4px;
    border-right: 2px solid #1a6bbf;
    border-bottom: 2px solid #1a6bbf;
  }
}

/* ホバー時に矢印を少し下にずらす */
@media (min-width: 768px) {
  .tab-list button:hover::before {
    transform: translateY(5px) rotate(45deg);
  }
}

/* アクティブなタブのスタイル */
.tab-list button.is-active {
  background: #eef2f7;
  color: #1a6bbf;
}

/* ===== パネル外枠 ===== */
/* overflow: hidden でアニメーション中にはみ出た要素を隠す */
.tab-panels-outer {
  position: relative;
  border-bottom-right-radius: 5px;
  border-bottom-left-radius: 5px;
  background: #eef2f7;
  overflow: hidden;
}

/* 各パネルは初期状態で非表示。JSで display: block に切り替える */
.tab-panel {
  display: none;
  width: 100%;
  box-sizing: border-box;
}

@media (min-width: 768px) {
  .tab-panel {
    padding: 30px;
  }
}

@media (max-width: 767px) {
  .tab-panel {
    padding: 20px 15px;
  }
}

/* 以降、パネルの中身に関するスタイルは省略 */

JavaScript

const tabs = (() => {
  // アニメーションの設定
  const props = {
    switchDuration: 500, // アニメーション時間(ミリ秒)
    switchEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)', // イージング
  };

  function init() {
    // 1番目のタブをアクティブにする
    document.querySelectorAll('.tabs').forEach((el) => {
      el.querySelectorAll('.tab-list li:nth-child(1) button').forEach(btn => btn.classList.add('is-active'));

      const panel = el.querySelector('.tab-panel:nth-child(1)');
      if (panel) {
        panel.classList.add('is-active');
        panel.style.display = 'block';
      }
    });

    // タブボタンにクリックイベントを登録する
    document.querySelectorAll('.tab-list button').forEach((btn) => {
      btn.addEventListener('click', (e) => {
        const btn = e.currentTarget;
        const tabsEl = btn.closest('.tabs');
        const id = btn.dataset.tab;

        // アニメーション中・すでにアクティブなタブはスキップする
        if (!tabsEl.classList.contains('is-animating') && !btn.classList.contains('is-active')) {
          tabSwitch(tabsEl, id);
        }
      });
    });
  }

  // タブを切り替える関数
  function tabSwitch(tabsEl, id) {
    const activePanel = tabsEl.querySelector('.tab-panel.is-active');
    const nextPanel = tabsEl.querySelector(`.tab-panel[data-panel="${id}"]`);

    // どちらかが見つからない場合は処理を中断する
    if (!activePanel || !nextPanel) return;

    // アニメーション中フラグを立てる(連打防止)
    tabsEl.classList.add('is-animating');

    const outer = tabsEl.querySelector('.tab-panels-outer');
    const startHeight = outer.offsetHeight;

    // タブボタンのアクティブ状態を切り替える
    tabsEl.querySelectorAll('.tab-list button.is-active').forEach(btn => btn.classList.remove('is-active'));
    tabsEl.querySelectorAll(`.tab-list button[data-tab="${id}"]`).forEach(btn => btn.classList.add('is-active'));

    // 現在のパネルを絶対配置にして、次のパネルの高さ計算に影響しないようにする
    Object.assign(activePanel.style, { position: 'absolute', left: '0', top: '0' });

    // 次のパネルを表示してから高さを測定する
    nextPanel.style.display = 'block';
    nextPanel.style.opacity = '0';

    const endHeight = outer.offsetHeight;

    // 外枠の高さをアニメーションさせる
    outer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.switchDuration,
      easing: props.switchEasing,
    });

    // 現在のパネルをフェードアウトさせる
    activePanel.animate([
      { opacity: 1 },
      { opacity: 0 }
    ], {
      duration: props.switchDuration,
      easing: props.switchEasing,
      fill: 'forwards',
    });

    // 次のパネルをフェードインさせる
    // アニメーション完了後に後処理を行う
    nextPanel.animate([
      { opacity: 0 },
      { opacity: 1 }
    ], {
      duration: props.switchDuration,
      easing: props.switchEasing,
      fill: 'forwards',
    }).onfinish = () => {
      // 高さを auto に戻す
      outer.style.height = 'auto';

      // 前のパネルを非表示にしてスタイルをリセットする
      activePanel.classList.remove('is-active');
      activePanel.style.display = 'none';
      Object.assign(activePanel.style, { position: 'static', opacity: '1' });

      // 次のパネルをアクティブにする
      nextPanel.classList.add('is-active');

      // アニメーション中フラグを解除する
      tabsEl.classList.remove('is-animating');
    };
  }

  return {
    init
  };
})();

tabs.init();

コードのポイント解説

ここでは、JavaScriptのコードを上から順に読み解いていきます。

① 初期化処理(1番目のタブをアクティブにする)

document.querySelectorAll('.tabs').forEach((el) => {
  el.querySelectorAll('.tab-list li:nth-child(1) button').forEach(btn => btn.classList.add('is-active'));

  const panel = el.querySelector('.tab-panel:nth-child(1)');
  if (panel) {
    panel.classList.add('is-active');
    panel.style.display = 'block';
  }
});

ページ読み込み時に、最初のタブをアクティブな状態にする処理です。

タブボタンには is-active クラスを付与します。これはCSSと連動していて、アクティブなタブの背景色・文字色を切り替えるために使います。

パネルにも同様に is-active クラスを付与します。こちらはCSS用ではなく、後述する tabSwitch 関数の中で「現在表示中のパネルはどれか」を特定するために使います。

あわせて、パネルには display: block も設定します。パネルはCSS側で display: none にしているため、JSで明示的に表示させる必要があります。

② クリックイベントの登録

document.querySelectorAll('.tab-list button').forEach((btn) => {
  btn.addEventListener('click', (e) => {
    const btn = e.currentTarget;
    const tabsEl = btn.closest('.tabs');
    const id = btn.dataset.tab;

    if (!tabsEl.classList.contains('is-animating') && !btn.classList.contains('is-active')) {
      tabSwitch(tabsEl, id);
    }
  });
});

すべてのタブボタンにクリックイベントを登録しています。

クリック時に btn.closest('.tabs') で、クリックされたボタンが属する .tabs 要素を取得しています。これにより、ページ内に複数のタブメニューがあっても、クリックされたタブが属するグループの中だけで処理が完結します。

また、切り替え処理を実行する前に2つの条件を確認しています。

  • is-animating クラスがついていないか(アニメーション中でないか)
  • クリックされたタブが、すでにアクティブでないか

この2つのガードにより、アニメーション中の連打や、同じタブの再クリックを無視できます。

③ タブ切り替え処理①(ボタンの状態切り替えと高さの計算)

function tabSwitch(tabsEl, id) {
  const activePanel = tabsEl.querySelector('.tab-panel.is-active');
  const nextPanel = tabsEl.querySelector(`.tab-panel[data-panel="${id}"]`);
  if (!activePanel || !nextPanel) return;

  tabsEl.classList.add('is-animating');

  const outer = tabsEl.querySelector('.tab-panels-outer');
  const startHeight = outer.offsetHeight;

  tabsEl.querySelectorAll('.tab-list button.is-active').forEach(btn => btn.classList.remove('is-active'));
  tabsEl.querySelectorAll(`.tab-list button[data-tab="${id}"]`).forEach(btn => btn.classList.add('is-active'));

  Object.assign(activePanel.style, { position: 'absolute', left: '0', top: '0' });

  nextPanel.style.display = 'block';
  nextPanel.style.opacity = '0';

  const endHeight = outer.offsetHeight;

関数の冒頭では、現在アクティブなパネルと次に表示するパネルを取得します。どちらかが見つからない場合は return で処理を中断します。

続いて is-animating クラスを付与し、アニメーション中フラグを立てます。これがクリックイベント側の連打防止ガードと連動しています。

次に高さのアニメーションに必要な準備をしています。

  1. startHeight に現在の外枠の高さを記録する
  2. 現在のパネルを position: absolute にして外枠のレイアウトから外す
  3. 次のパネルを display: blockopacity: 0 で表示する(見えない状態で配置)
  4. endHeight に切り替え後の外枠の高さを測定する

この手順により、アニメーション前後の高さを正確に取得できます。次のパネルを一瞬 opacity: 0 で表示してから高さを測るのがポイントです。

④ タブ切り替え処理②(Web Animation APIによるアニメーション実行)

outer.animate([
    { height: `${startHeight}px` },
    { height: `${endHeight}px` }
  ], {
    duration: props.switchDuration,
    easing: props.switchEasing,
  });

  activePanel.animate([
    { opacity: 1 },
    { opacity: 0 }
  ], {
    duration: props.switchDuration,
    easing: props.switchEasing,
    fill: 'forwards',
  });

  nextPanel.animate([
    { opacity: 0 },
    { opacity: 1 }
  ], {
    duration: props.switchDuration,
    easing: props.switchEasing,
    fill: 'forwards',
  })

Web Animation API.animate() を使って、3つのアニメーションを同時に実行しています。

  • 外枠の高さstartHeight から endHeight へ変化
  • 現在のパネル:フェードアウト(opacity: 1 → 0)
  • 次のパネル:フェードイン(opacity: 0 → 1)

fill: 'forwards' は、アニメーション終了後もその状態(opacity: 0 または 1)を維持するオプションです。これを指定しないと、アニメーション終了後に元の状態に戻ってしまいます。

⑤ タブ切り替え処理③(onfinish による後処理)

.onfinish = () => {
    outer.style.height = 'auto';

    activePanel.classList.remove('is-active');
    activePanel.style.display = 'none';
    Object.assign(activePanel.style, { position: 'static', opacity: '1' });

    nextPanel.classList.add('is-active');
    tabsEl.classList.remove('is-animating');
  };
}

次のパネルのフェードインが完了したタイミングで onfinish が呼ばれます。

ここで行っていることは4つです。

  • 外枠の高さを auto に戻す(アニメーション中はpx値で固定しているため、終了後に auto へ戻すことで、ウィンドウリサイズや文字サイズの変更などによるパネルの高さ変化に自然に追従できるようにするため)
  • 前のパネルを非表示にし、スタイルをリセットする
  • 次のパネルに is-active クラスを付与する
  • is-animating クラスを外してアニメーション中フラグを解除する

デモ02:上下タブ連動・外部リンク対応

完成デモの紹介

続いて、デモ02をご確認ください。

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

フォント選定のイメージ

TOPIC 01

Webサイトの印象を決める
フォント選定の基本

フォント選びはWebサイトの第一印象を大きく左右します。日本語フォントは文字数が多くファイルサイズが大きくなりやすいため、サブセット化やGoogle Fontsの活用が表示速度対策として有効です。

Webフォントの種類

WOFF2形式が現在の主流。圧縮率が高くほぼ全ブラウザで対応しており、読み込みを速くできます。

フォントの数は絞る

1ページで使うフォントは2〜3種類が目安。増やしすぎると読み込みが遅くなり統一感も失われます。

サブセット化とは

使う文字だけを抽出してファイルを軽量化する手法。日本語サイトでは特に効果的です。

font-displayの設定

font-display: swap を指定するとフォント読み込み中も代替フォントで文字が表示され、UXが向上します。

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

レイアウト設計のイメージ

TOPIC 02

CSSだけで組む
レスポンシブレイアウト設計

FlexboxとCSS Gridを使い分けることで、複雑なレイアウトもシンプルなコードで実現できます。メディアクエリに頼りすぎず、コンテナクエリやclamp()を活用した流動的な設計が近年のトレンドです。

FlexboxとGridの使い分け

1方向の並びはFlexbox、2次元の格子状レイアウトはGridが適しています。

コンテナクエリ

親要素の幅に応じてスタイルを切り替える仕組み。コンポーネント単位での柔軟な設計が可能です。

clamp()の活用

最小値・推奨値・最大値を一度に指定できる関数。フォントサイズや余白の流動的な設定に便利です。

モバイルファースト

小さい画面のスタイルをベースに書き、min-widthで大きい画面向けに上書きする設計手法です。

  • グリッドシステムを自作するよりCSS Gridのauto-fill/auto-fitを活用する方がコードが短い
  • 余白はpxではなくremやemで統一すると、フォントサイズ変更時に崩れにくくなる
  • デザインカンプがなくてもFigmaでおおまかなワイヤーを引いてから実装するとミスが減る

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

画像最適化のイメージ

TOPIC 03

表示速度に直結する
画像最適化の実践

画像はページの読み込みサイズの大半を占めることが多く、最適化の効果が最も出やすい分野です。WebP形式への変換やlazy loadingの活用で、Core Web Vitalsのスコア改善につながります。

WebP形式

PNGやJPEGより高圧縮でありながら画質を保てる次世代フォーマット。主要ブラウザはほぼ対応済みです。

lazy loading

imgタグにloading=”lazy”を付けるだけで、画面外の画像を後から読み込む遅延ロードが実現できます。

このパネルの内容はダミーです。タブの表示・切り替え動作をご確認ください。

パフォーマンスのイメージ

TOPIC 04

スコアだけじゃない
体感速度を高める
Web最適化

Lighthouseのスコアを上げることも重要ですが、ユーザーが「速い」と感じる体感速度の向上も同様に大切です。スケルトンスクリーンやプログレッシブレンダリングなど、体感UXを改善する手法も押さえておきましょう。

Core Web Vitals

LCP・INP・CLSの3指標をGoogleが評価。SEOにも影響するため、定期的な計測と改善が重要です。

キャッシュ制御

Cache-Controlヘッダーを適切に設定することで、リピーターの読み込みを大幅に短縮できます。

JavaScriptの遅延読み込み

defer・async属性でHTMLのパース完了後にJSを実行させ、初期描画のブロックを防ぎます。

CDNの活用

静的ファイルをCDNで配信することで、世界中のユーザーへの応答時間を均一に短縮できます。

  • PageSpeed InsightsとChrome DevToolsを組み合わせてボトルネックを特定するのが改善の近道
  • フォントの読み込みにrel=”preload”を使うと、FCP(最初のコンテンツ描画)を早めやすい

デモ01との違いは3点です。

  • パネルの下にも同じタブが表示されている
  • 下部タブをクリックすると、上部タブの位置までスクロールしてからタブが切り替わる
  • URLのクエリパラメータで特定のタブを開いた状態にできる(例:?demo02=layout

コード全体

HTML

<!-- タブ全体のラッパー。data-tab-group でグループを識別する -->
<div class="tabs" data-tab-group="demo02">

  <!-- 上部タブ -->
  <ul class="tab-list tab-list--top">
    <!-- data-tab にパネルのIDを指定する -->
    <li><button type="button" data-tab="font">フォント<br class="only-sp">選定</button></li>
    <li><button type="button" data-tab="layout">レイアウト<br class="only-sp">設計</button></li>
    <li><button type="button" data-tab="image">画像<br class="only-sp">最適化</button></li>
    <li><button type="button" data-tab="performance">パフォー<br class="only-sp">マンス</button></li>
  </ul>

  <!-- パネルの外枠。高さのアニメーションはこの要素に対して行う -->
  <div class="tab-panels-outer">

    <!-- data-panel はタブの data-tab と対応させる -->
    <div class="tab-panel" data-panel="font">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

    <div class="tab-panel" data-panel="layout">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

    <div class="tab-panel" data-panel="image">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

    <div class="tab-panel" data-panel="performance">
      <!-- パネルの中身(任意のHTMLを記述) -->
    </div>

  </div><!-- /.tab-panels-outer -->

  <!-- 下部タブ。data-tab はタブ上部と同じ値を指定する -->
  <ul class="tab-list tab-list--bottom">
    <li><button type="button" data-tab="font">フォント<br class="only-sp">選定</button></li>
    <li><button type="button" data-tab="layout">レイアウト<br class="only-sp">設計</button></li>
    <li><button type="button" data-tab="image">画像<br class="only-sp">最適化</button></li>
    <li><button type="button" data-tab="performance">パフォー<br class="only-sp">マンス</button></li>
  </ul>

</div><!-- /.tabs -->

CSS

/* ブレイクポイント:768px以上をPC、767px以下をSPとして扱う */

/* ===== タブリスト共通 ===== */
.tab-list {
  display: flex;
}

@media (min-width: 768px) {
  .tab-list {
    gap: 10px;
  }
}

@media (max-width: 767px) {
  .tab-list {
    gap: 5px;
  }
}

/* 各タブを均等幅に並べる */
.tab-list li {
  flex: 1;
}

/* タブボタンの共通スタイル */
.tab-list button {
  display: flex;
  justify-content: center;
  align-items: center;
  position: relative;
  width: 100%;
  box-sizing: border-box;
  border: none;
  background: #f8f8f8;
  font: inherit;
  font-weight: 700;
  text-align: center;
  cursor: pointer;
  /* 背景色・文字色の切り替えをなめらかにする */
  transition: background-color 500ms cubic-bezier(.215, .61, .355, 1), color 500ms cubic-bezier(.215, .61, .355, 1);
}

@media (min-width: 768px) {
  .tab-list button {
    height: 80px;
    font-size: 18px;
    line-height: 1.5;
  }
}

@media (max-width: 767px) {
  .tab-list button {
    height: 70px;
    font-size: 13px;
    line-height: 1.4;
  }
}

/* 矢印アイコン(::before 擬似要素で描画) */
.tab-list button::before {
  content: "";
  display: block;
  position: absolute;
  left: 50%;
  box-sizing: border-box;
  transition: transform 300ms cubic-bezier(.215, .61, .355, 1);
}

@media (min-width: 768px) {
  .tab-list button::before {
    width: 11px;
    height: 11px;
    margin-left: -5.5px;
    border-right: 3px solid #1a6bbf;
  }
}

@media (max-width: 767px) {
  .tab-list button::before {
    width: 8px;
    height: 8px;
    margin-left: -4px;
    border-right: 2px solid #1a6bbf;
  }
}

/* アクティブなタブのスタイル */
.tab-list button.is-active {
  background: #eef2f7;
  color: #1a6bbf;
}

/* ===== 上部タブ ===== */
.tab-list--top button {
  border-top-right-radius: 5px;
  border-top-left-radius: 5px;
}

@media (min-width: 768px) {
  .tab-list--top button {
    padding: 0 0 18px;
  }
}

@media (max-width: 767px) {
  .tab-list--top button {
    padding: 0 0 15px;
  }
}

/* 上部タブの矢印は下向き(↓) */
.tab-list--top button::before {
  transform: rotate(45deg);
}

@media (min-width: 768px) {
  .tab-list--top button::before {
    bottom: 15px;
    border-bottom: 3px solid #1a6bbf;
  }
}

@media (max-width: 767px) {
  .tab-list--top button::before {
    bottom: 12px;
    border-bottom: 2px solid #1a6bbf;
  }
}

/* ホバー時に矢印を少し下にずらす */
@media (min-width: 768px) {
  .tab-list--top button:hover::before {
    transform: translateY(5px) rotate(45deg);
  }
}

/* ===== 下部タブ ===== */
.tab-list--bottom button {
  border-bottom-right-radius: 5px;
  border-bottom-left-radius: 5px;
}

@media (min-width: 768px) {
  .tab-list--bottom button {
    padding: 18px 0 0;
  }
}

@media (max-width: 767px) {
  .tab-list--bottom button {
    padding: 15px 0 0;
  }
}

/* 下部タブの矢印は上向き(↑) */
.tab-list--bottom button::before {
  transform: rotate(-45deg);
}

@media (min-width: 768px) {
  .tab-list--bottom button::before {
    top: 15px;
    border-top: 3px solid #1a6bbf;
  }
}

@media (max-width: 767px) {
  .tab-list--bottom button::before {
    top: 12px;
    border-top: 2px solid #1a6bbf;
  }
}

/* ホバー時に矢印を少し上にずらす */
@media (min-width: 768px) {
  .tab-list--bottom button:hover::before {
    transform: translateY(-5px) rotate(-45deg);
  }
}

/* ===== パネル外枠 ===== */
/* overflow: hidden でアニメーション中にはみ出た要素を隠す */
.tab-panels-outer {
  position: relative;
  background: #eef2f7;
  overflow: hidden;
}

/* 各パネルは初期状態で非表示。JSで display: block に切り替える */
.tab-panel {
  display: none;
  width: 100%;
  box-sizing: border-box;
}

@media (min-width: 768px) {
  .tab-panel {
    padding: 30px;
  }
}

@media (max-width: 767px) {
  .tab-panel {
    padding: 20px 15px;
  }
}

/* 以降、パネルの中身に関するスタイルは省略 */

JavaScript

const tabs = (() => {
  // アニメーションの設定
  const props = {
    switchDuration: 500, // アニメーション時間(ミリ秒)
    switchEasing: 'cubic-bezier(0.215, 0.61, 0.355, 1)', // イージング
  };

  function init() {
    // URLのクエリパラメータを取得する
    // 例)?demo02=layout の場合、"layout" タブを初期表示する
    const params = new URLSearchParams(location.search);

    document.querySelectorAll('.tabs').forEach((el) => {
      const groupId = el.dataset.tabGroup;
      const activeTab = params.get(groupId);

      // クエリパラメータに一致するパネルを探す
      const matchedPanel = activeTab ? el.querySelector(`.tab-panel[data-panel="${activeTab}"]`) : null;

      if (matchedPanel) {
        // 一致するパネルがあれば、そのタブをアクティブにする
        el.querySelectorAll(`.tab-list button[data-tab="${activeTab}"]`).forEach(btn => btn.classList.add('is-active'));
        matchedPanel.classList.add('is-active');
        matchedPanel.style.display = 'block';

      } else {
        // クエリパラメータがない場合は、1番目のタブをアクティブにする
        el.querySelectorAll('.tab-list li:nth-child(1) button').forEach(btn => btn.classList.add('is-active'));

        const panel = el.querySelector('.tab-panel:nth-child(1)');
        if (panel) {
          panel.classList.add('is-active');
          panel.style.display = 'block';
        }
      }
    });

    // タブボタンにクリックイベントを登録する
    document.querySelectorAll('.tab-list button').forEach((btn) => {
      btn.addEventListener('click', (e) => {
        const btn = e.currentTarget;
        const tabsEl = btn.closest('.tabs');
        const id = btn.dataset.tab;
        const topOffset = tabsEl.getBoundingClientRect().top;

        // スクロール位置がタブより上にある場合、タブの位置までスクロールする
        if (topOffset < 0) {
          scrollToY(window.scrollY + topOffset, props.switchDuration);
        }

        // アニメーション中・すでにアクティブなタブはスキップする
        if (!tabsEl.classList.contains('is-animating') && !btn.classList.contains('is-active')) {
          tabSwitch(tabsEl, id);
        }
      });
    });
  }

  // 指定した位置までなめらかにスクロールする関数
  function scrollToY(targetY, duration) {
    const startY = window.scrollY;
    const diff = targetY - startY;
    let startTime = null;

    function step(timestamp) {
      if (!startTime) startTime = timestamp;
      const elapsed = timestamp - startTime;
      const progress = Math.min(elapsed / duration, 1);
      // easeOutCubic のイージングを適用する
      const ease = 1 - Math.pow(1 - progress, 3);
      window.scrollTo(0, startY + diff * ease);
      if (elapsed < duration) requestAnimationFrame(step);
    }

    requestAnimationFrame(step);
  }

  // タブを切り替える関数
  function tabSwitch(tabsEl, id) {
    const activePanel = tabsEl.querySelector('.tab-panel.is-active');
    const nextPanel = tabsEl.querySelector(`.tab-panel[data-panel="${id}"]`);

    // どちらかが見つからない場合は処理を中断する
    if (!activePanel || !nextPanel) return;

    // アニメーション中フラグを立てる(連打防止)
    tabsEl.classList.add('is-animating');

    const outer = tabsEl.querySelector('.tab-panels-outer');
    const startHeight = outer.offsetHeight;

    // タブボタンのアクティブ状態を切り替える(上部・下部タブ両方)
    tabsEl.querySelectorAll('.tab-list button.is-active').forEach(btn => btn.classList.remove('is-active'));
    tabsEl.querySelectorAll(`.tab-list button[data-tab="${id}"]`).forEach(btn => btn.classList.add('is-active'));

    // 現在のパネルを絶対配置にして、次のパネルの高さ計算に影響しないようにする
    Object.assign(activePanel.style, { position: 'absolute', left: '0', top: '0' });

    // 次のパネルを表示してから高さを測定する
    nextPanel.style.display = 'block';
    nextPanel.style.opacity = '0';

    const endHeight = outer.offsetHeight;

    // 外枠の高さをアニメーションさせる
    outer.animate([
      { height: `${startHeight}px` },
      { height: `${endHeight}px` }
    ], {
      duration: props.switchDuration,
      easing: props.switchEasing,
    });

    // 現在のパネルをフェードアウトさせる
    activePanel.animate([
      { opacity: 1 },
      { opacity: 0 }
    ], {
      duration: props.switchDuration,
      easing: props.switchEasing,
      fill: 'forwards',
    });

    // 次のパネルをフェードインさせる
    // アニメーション完了後に後処理を行う
    nextPanel.animate([
      { opacity: 0 },
      { opacity: 1 }
    ], {
      duration: props.switchDuration,
      easing: props.switchEasing,
      fill: 'forwards',
    }).onfinish = () => {
      // 高さを auto に戻す
      outer.style.height = 'auto';

      // 前のパネルを非表示にしてスタイルをリセットする
      activePanel.classList.remove('is-active');
      activePanel.style.display = 'none';
      Object.assign(activePanel.style, { position: 'static', opacity: '1' });

      // 次のパネルをアクティブにする
      nextPanel.classList.add('is-active');

      // アニメーション中フラグを解除する
      tabsEl.classList.remove('is-animating');
    };
  }

  return {
    init
  };
})();

tabs.init();

コードのポイント解説

HTMLとCSSについては、デモ01との差分を中心に確認します。JavaScriptについてはデモ01にない機能に絞って解説します。

HTML の差分

デモ01との違いは2点です。

1つ目は、タブリストのクラスが変わっています。デモ01では .tab-list のみでしたが、デモ02では .tab-list--top.tab-list--bottom を追加しています。上下で矢印の向きやボーダーの位置を変えるために使います。

<!-- デモ01 -->
<ul class="tab-list">

<!-- デモ02(上部) -->
<ul class="tab-list tab-list--top">

<!-- デモ02(下部) -->
<ul class="tab-list tab-list--bottom">

2つ目は、パネルの外枠(.tab-panels-outer)の後ろに、下部タブリストが追加されています。

</div><!-- /.tab-panels-outer -->

<!-- 下部タブ。data-tab はタブ上部と同じ値を指定する -->
<ul class="tab-list tab-list--bottom">
  <li><button type="button" data-tab="font">...</button></li>
  ...
</ul>

CSS の差分

デモ01との違いは、.tab-list--top.tab-list--bottom のスタイルが追加されている点です。

上部タブは上角を丸く、下部タブは下角を丸くしています。また、矢印の向きを上下で変えています。

/* 上部タブ:上角を丸くして矢印を下向きに */
.tab-list--top button {
  border-top-right-radius: 5px;
  border-top-left-radius: 5px;
}
.tab-list--top button::before {
  transform: rotate(45deg); /* 下向き矢印 */
}

/* 下部タブ:下角を丸くして矢印を上向きに */
.tab-list--bottom button {
  border-bottom-right-radius: 5px;
  border-bottom-left-radius: 5px;
}
.tab-list--bottom button::before {
  transform: rotate(-45deg); /* 上向き矢印 */
}

JavaScript の差分①:URLパラメータによる初期タブの指定

const params = new URLSearchParams(location.search);

document.querySelectorAll('.tabs').forEach((el) => {
  const groupId = el.dataset.tabGroup;
  const activeTab = params.get(groupId);
  const matchedPanel = activeTab ? el.querySelector(`.tab-panel[data-panel="${activeTab}"]`) : null;

  if (matchedPanel) {
    el.querySelectorAll(`.tab-list button[data-tab="${activeTab}"]`).forEach(btn => btn.classList.add('is-active'));
    matchedPanel.classList.add('is-active');
    matchedPanel.style.display = 'block';
  } else {
    // クエリパラメータがない場合は1番目のタブを開く(デモ01と同じ処理)
    ...
  }
});

URLSearchParams を使って、URLのクエリパラメータを取得しています。

たとえば、この記事のURLの末尾に ?demo02=layout を付けてアクセスすると、デモ02の「レイアウト設計」タブが最初から開いた状態になります。ぜひ試してみてください。

el.dataset.tabGroup.tabs 要素の data-tab-group 属性の値(demo02)を取得し、それをキーにしてパラメータを検索しています。一致するパネルが見つかればそのタブを開き、見つからなければデモ01と同様に1番目のタブを開きます。

外部ページから「このタブを開いた状態でリンクしたい」という場面で活用できます。

const topOffset = tabsEl.getBoundingClientRect().top;

if (topOffset < 0) {
  scrollToY(window.scrollY + topOffset, props.switchDuration);
}
function scrollToY(targetY, duration) {
  const startY = window.scrollY;
  const diff = targetY - startY;
  let startTime = null;

  function step(timestamp) {
    if (!startTime) startTime = timestamp;
    const elapsed = timestamp - startTime;
    const progress = Math.min(elapsed / duration, 1);
    const ease = 1 - Math.pow(1 - progress, 3);
    window.scrollTo(0, startY + diff * ease);
    if (elapsed < duration) requestAnimationFrame(step);
  }

  requestAnimationFrame(step);
}

下部タブをクリックしたとき、ページが上部タブより下にスクロールしている場合は、タブ切り替えと同時に上部タブの位置までスクロールします。

getBoundingClientRect().top は、要素のビューポート上端からの距離を返します。この値が0より小さい場合、タブがビューポートの上端より上にスクロールされていることを意味します。その場合に scrollToY 関数を呼び出し、上部タブの位置まで戻します。

scrollToY 関数に渡すスクロール先は window.scrollY + topOffset で計算します。現在のスクロール量に topOffset(負の値)を足すことで、タブがビューポートの上端にちょうど合う絶対位置を求めています。

scrollToY 関数では、requestAnimationFrame を使って毎フレームごとにスクロール位置を更新しています。

イージングの計算式(1 - Math.pow(1 - progress, 3))は、easeOutCubicと呼ばれる種類のイージングで、終盤に向かってなめらかに減速する動きを作ります。自力で導くのは難しいですが、このままコピペして使えます。他のイージングに変えたい場合は、イージング関数チートシートを参照してください。各イージングの数学関数も掲載されているので、そのままコードに組み込めます。

まとめ

本記事では、jQueryなし・Vanilla JSのみで実装するタブメニューを2パターン紹介しました。

  • デモ01:ページ内に複数設置できるシンプルなタブ切り替え
  • デモ02:上下タブの連動・スムーズスクロール・外部リンク対応を追加

どちらも完成コードをそのままコピーして使えます。data-tab-group の値を変えるだけで複数のグループを同一ページに設置できるので、実務でも取り入れやすい設計になっています。

タブの数を増減したい場合は、HTMLにタブボタンとパネルを追加するだけで対応できます。デザインのカスタマイズも、CSSの色や高さを調整するだけで柔軟に対応できます。

コードの仕組みを理解しておくと、プロジェクトごとの要件に合わせた調整や応用もしやすくなります。ぜひ実務で活用してみてください。

押していただけると励みになります!

ABOUT ME
みなと
みなと
フロントエンドエンジニア
東京のWeb制作会社で15年以上働いている現役フロントエンドエンジニアです。これまで、いろんなプロジェクトに関わりながら、フロントエンド開発やWebデザインに取り組んできました。このブログでは、今までの経験を活かして、Web制作に役立つ情報やノウハウをシェアしていきたいと思います。初心者の方から、現場で働く方まで、誰でも参考にできる内容をお届けしますので、ぜひ覗いてみてください。
記事URLをコピーしました