スクロール位置に合わせて目次を切り替える。JavaScriptで現在の見出しを判定してみた

スクロール位置に合わせてH2・H3の目次active表示を切り替えるCODE記事アイキャッチ

MANIARUのカテゴリー記事では、本文を読み進めると、現在読んでいるH2またはH3に対応する目次項目が青く切り替わります。PCでは右側の追従目次、モバイルでは記事冒頭の折りたたみ目次へ同じ状態を反映しています。

この記事では、そのactive表示を切り替えるJavaScriptについて、完成時の動き、WordPressでコードを書いている場所、必要なHTMLと変数、最小構成のコード、確認方法まで順番に整理します。

WordPressの記事に追従する目次を作る。H2/H3と現在位置を連動させてみたでは、目次全体の設計をまとめています。今回はその中から、スクロール位置の判定とactive切り替えに絞ります。

完成するとどう動くか

見出しが固定ヘッダー下の基準位置を通過すると、その見出しと同じIDを指す目次リンクへis-activeが付きます。次の見出しまで進むと、active表示も次の項目へ移ります。

スクロール位置に合わせて右側の目次項目が青く切り替わるMANIARUの記事画面
見出しが基準位置を通過すると、同じ見出しを指す目次項目が青く切り替わる。
  • 本文のH2とH3を上から順番に取得する
  • 本文見出しのIDと目次リンクのhrefを対応させる
  • 基準位置を通過した最後の見出しを現在位置とする
  • 対応するリンクだけにis-activeを付ける
  • ページ最下部では最後の見出しを選ぶ

仕組みはIDで見出しと目次をつなぐ

active表示の中心は、本文見出しのidと、目次リンクのhrefを同じ値にすることです。

<h2 id="section-1">最初の見出し</h2>
<a href="#section-1">最初の見出し</a>

JavaScriptは現在の見出しを決めたあと、#section-1のような文字列を作り、同じhrefを持つ目次リンクへactive classを付けます。この対応がずれていると、スクロール位置を判定できても目次表示は切り替わりません。

MANIARUではどこにJavaScriptを書いているか

現在のMANIARUでは、WordPress管理画面の「外観 → ウィジェット → サイドバー」にある「カスタムHTML」ブロックへ共通スクリプトを置いています。

  1. WordPress管理画面で「外観」を開く
  2. 「ウィジェット」を開く
  3. 「サイドバー」を展開する
  4. サイドバー内の「カスタムHTML」を開く
  5. <script>内にあるカテゴリー目次用の自己実行関数を確認する

記事ごとにJavaScriptを書くのではなく、AI / WEB / CODE / BUILD / GROW / MONEYの記事で使う処理を一つの関数として置いています。記事本文の見出しからPC用とモバイル用の目次を生成し、その後にactive判定を設定する構成です。

これは現在のMANIARUで確認できた配置場所です。テーマやサイトによって共通JavaScriptの管理方法は異なります。カスタムHTMLへそのまま貼ることをWordPress全体の標準手順とはせず、自分の環境の管理方法に合わせる必要があります。

実装前に必要な要素を確認する

名前役割
content見出しを探す記事本文の要素
headings本文から取得したH2/H3の配列
tocLinks本文見出しと同じIDを指す目次リンクの配列
is-active現在位置のリンクへ付けるclass
112固定ヘッダーを避けるためのMANIARU固有の基準位置

以前の掲載コードではcontentとtocLinksの定義を省いていたため、コード断片だけをコピーしても動きませんでした。以下では、仕組みを単独で試せる最小構成としてHTML、CSS、JavaScriptをまとめます。

最小構成をコピーして試す

この例はactive切り替えだけを確認するための汎用的な最小構成です。公開中のWordPressへ直接追加せず、ローカルの単一HTMLファイルとして試します。サーバーや追加ツールは必要ありません。

1. テスト用のHTMLファイルを用意する

  1. テキストエディタで新しいファイルを作る
  2. ファイル名をactive-toc-demo.htmlにする
  3. 次の完成コードをすべてコピーして貼り付ける
  4. UTF-8で保存し、ファイルをブラウザで開く

HTMLは<body>内、CSSは<style>内、JavaScriptは末尾の<script>内にまとめています。三つを別の場所へ貼り分ける必要はありません。

2. 完成コードをコピーする

<!doctype html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>スクロール連動目次のデモ</title>
  <style>
    * { box-sizing: border-box; }

    body {
      margin: 0;
      color: #1b2638;
      font-family: sans-serif;
    }

    .demo-toc {
      position: sticky;
      top: 0;
      z-index: 10;
      display: flex;
      flex-wrap: wrap;
      gap: 8px;
      padding: 12px 16px;
      background: #fff;
      border-bottom: 1px solid #e3e9f0;
    }

    .demo-toc a {
      padding: 6px 10px;
      color: #475569;
      text-decoration: none;
    }

    .demo-toc a.is-active {
      color: #1d5fd1;
      background: #f1f6ff;
    }

    .demo-content {
      max-width: 720px;
      margin: 0 auto;
      padding: 32px 20px 0;
    }

    .demo-section {
      min-height: 90vh;
      padding-top: 24px;
    }
  </style>
</head>
<body>
  <nav class="demo-toc" aria-label="この記事の目次">
    <a href="#section-1">最初の見出し</a>
    <a href="#section-2">次の見出し</a>
    <a href="#section-3">最後の見出し</a>
  </nav>

  <main class="demo-content">
    <section class="demo-section">
      <h2 id="section-1">最初の見出し</h2>
      <p>初期表示では、この見出しに対応する項目が青くなります。</p>
    </section>

    <section class="demo-section">
      <h2 id="section-2">次の見出し</h2>
      <p>ここまでスクロールすると、active表示が次へ移ります。</p>
    </section>

    <section class="demo-section">
      <h2 id="section-3">最後の見出し</h2>
      <p>ページ最下部では、この項目がactiveになります。</p>
    </section>
  </main>

  <script>
(function () {
  var content = document.querySelector('.demo-content');
  if (!content) return;

  var headings = Array.prototype.slice.call(
    content.querySelectorAll('h2, h3')
  );
  var tocLinks = Array.prototype.slice.call(
    document.querySelectorAll('.demo-toc a[href^="#"]')
  );

  if (!headings.length || !tocLinks.length) return;

  function setActive(heading) {
    var href = '#' + heading.id;

    tocLinks.forEach(function (link) {
      link.classList.toggle(
        'is-active',
        link.getAttribute('href') === href
      );
    });
  }

  function updateActive() {
    var current = headings[0];

    headings.forEach(function (heading) {
      if (heading.getBoundingClientRect().top <= 112) {
        current = heading;
      }
    });

    if (
      window.innerHeight + window.scrollY >=
      document.documentElement.scrollHeight - 2
    ) {
      current = headings[headings.length - 1];
    }

    setActive(current);
  }

  if ('IntersectionObserver' in window) {
    var observer = new IntersectionObserver(updateActive, {
      rootMargin: '-112px 0px -65% 0px',
      threshold: [0, 1]
    });

    headings.forEach(function (heading) {
      observer.observe(heading);
    });
  }

  window.addEventListener('load', updateActive, { once: true });
  updateActive();
})();
  </script>
</body>
</html>

.demo-sectionのmin-height: 90vhは、デモで十分なスクロール量を作るための指定です。本番サイトで同じ高さを設定する必要はありません。

3. 初期・中間・最下部を確認する

  • ファイルを開いた直後は「最初の見出し」が青くなる
  • 「次の見出し」が画面上部の基準位置まで来ると、青い項目が二つ目へ移る
  • ページ最下部まで進むと「最後の見出し」が青くなる

この三つを確認できれば、見出しの取得、IDとhrefの対応、現在位置の判定、最下部補正まで動いています。最小例はテスト用で、MANIARU本番のPC/モバイル目次生成や記事カテゴリー判定は含めていません。

コードの重要部分を確認する

content、headings、tocLinksを先に定義する

contentは見出しを探す範囲です。そこからH2/H3を取得してheadingsへ入れます。tocLinksには、現在位置を反映するすべての目次リンクを入れます。

MANIARU本番では.p-entry__contentを本文として使い、生成済みの.maniaru-category-toc__list aをPC・モバイル分まとめて取得しています。

112pxを通過した最後の見出しを選ぶ

getBoundingClientRect().topは、見出し上端が画面上端から何pxの位置にあるかを返します。MANIARUでは固定ヘッダーと上側の余白を考えて112pxを使っています。

この値はサイト共通の正解ではありません。ヘッダーの高さが80pxなら、80px前後を基準にするなど、自分の画面構成に合わせます。

一致するリンクだけへis-activeを付ける

classList.toggleの第2引数へ真偽値を渡すと、条件がtrueのリンクにはclassを付け、falseのリンクからは外せます。これにより、現在の見出しと同じIDを指すリンクだけがactiveになります。

IntersectionObserverは更新のきっかけにする

IntersectionObserverが現在の見出しを直接決めるわけではありません。見出しと監視範囲の交差状態が変わったときにupdateActive()を呼び、その関数が全見出しの位置から現在位置を一つに決めます。

スクリプト実行時とload時にもupdateActive()を呼び、初期表示を補正します。ページ最下部では最後の見出しを明示的に選びます。

MANIARU本番の実装との違い

最小例では、目次リンクと見出しIDをHTMLへ直接書きました。MANIARU本番では、記事本文からH2/H3を取得し、IDがない見出しへmaniaru-heading-1のような連番を実行時に付けます。

  • 本文は.p-entry__contentから取得する
  • H2を上位、H3を直前のH2配下として目次DOMを作る
  • PC用のサイドバー目次とモバイル用の折りたたみ目次を作る
  • 両方の目次リンクをtocLinksへ入れる
  • sticky表示はCSS、active判定はJavaScriptに分ける

この部分はMANIARUのカテゴリー、テーマ構造、既存目次に合わせた処理です。最小例のセレクターを自分のHTMLへ合わせることと、MANIARU固有処理をそのまま流用することは分けて考える必要があります。

WordPressへ反映して動作を確認する

MANIARUで共通スクリプトを変更するときは、カスタムHTMLの元コードを手元へ控えてから編集します。変更後にウィジェットを更新し、公開記事を再読み込みして確認します。

  1. PCで記事を開き、右側の目次が表示されることを確認する
  2. 本文をスクロールし、基準位置を通過した見出しと同じ項目が青くなることを確認する
  3. 次のH2またはH3まで進み、active表示が一つ先へ移ることを確認する
  4. ページ最下部で最後の目次項目がactiveになることを確認する
  5. スマートフォン幅で記事冒頭の目次を開き、同じ項目へactiveが反映されることを確認する

この状態になれば、本文見出しの取得、IDとhrefの対応、現在位置の判定、active classの切り替えまで動いています。

うまく切り替わらないときに確認すること

次はMANIARUで起きた失敗談ではなく、同じ仕組みを別環境へ合わせるときの一般的な確認ポイントです。

  • 見出しを取得できない:.demo-contentや.p-entry__contentが実際の本文要素と一致しているか確認する
  • リンクが切り替わらない:見出しのidと目次リンクのhrefが同じ文字列か確認する
  • classは付くが見た目が変わらない:.is-active用のCSSがあるか確認する
  • 切り替わる位置がずれる:112pxを自分の固定ヘッダーの高さに合わせる
  • 変更が反映されない:ウィジェットを更新したか、ブラウザの再読み込み後も同じか確認する

実装して分かったこと

目次のactive表示は、スクロール量だけで記事全体を大まかに区切るより、実際の見出し位置とIDを使う方が記事構造へ合わせやすいと分かりました。

一方で、見出しを検出するだけでは完成しません。本文の範囲、目次リンク、ID、固定ヘッダーの高さ、初期表示、ページ最下部まで揃って、読んでいる位置と目次表示がつながります。

MANIARUでは、目次生成とactive切り替えを同じ共通スクリプト内で役割ごとに分けました。記事ごとに見出し名を書く必要がなく、新しいH2/H3を追加しても同じ流れで目次へ反映できます。

関連記事