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

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

MANIARUのカテゴリー記事では、PCのサイドバーに記事内目次を表示しています。本文を読み進めると、現在読んでいるH2またはH3に対応する項目だけが青く切り替わります。

WordPressの記事に追従する目次を作る。H2/H3と現在位置を連動させてみたでは、WordPressのUXとして目次全体をどう設計したかをまとめました。

今回はその中から、スクロール位置を基に現在の見出しを決め、目次のactive表示を切り替えるJavaScriptに絞ります。ここに掲載するコードは、MANIARUで動いている処理から記事の中心部分を抜き出したものです。

なぜ目次の現在位置を切り替えたかったか

長い記事では、目次が見えていても、本文のどこを読んでいるかまでは分かりません。特にH2とH3が混在すると、見出し名を目で探して現在位置を確認する必要があります。

そこで、本文のスクロール位置に合わせて目次の表示を切り替えることにしました。強いアニメーションは使わず、該当項目の文字色と背景を控えめに変えています。

今回作るもの

  • 本文内のH2とH3を上から順番に取得する
  • 各見出しと目次リンクをIDで対応させる
  • 基準位置を通過した最後の見出しを現在位置とする
  • 対応する目次リンクへis-activeを付ける
  • ページ最下部では最後の見出しを選ぶ

MANIARUではIntersectionObserverを更新のきっかけに使い、実際の判定は各見出しの画面内位置から行っています。

見出しと目次をIDで対応させる

目次リンクは#見出しIDへ移動するアンカーです。見出しがid="maniaru-heading-3"なら、目次側のリンクはhref="#maniaru-heading-3"になります。

この共通のIDがあることで、「現在の見出し」と「切り替える目次項目」を同じ文字列で照合できます。

JavaScriptでH2とH3を取得する

現在の実装では、記事本文の要素からH2とH3をまとめて取得します。目次UIの中に見出しが含まれる場合は、それを除外します。

var headings = Array.prototype.slice.call(
  content.querySelectorAll('h2, h3')
).filter(function (heading) {
  return !heading.closest(
    '.maniaru-category-mobile, .maniaru-project-mobile'
  );
});

headings.forEach(function (heading, index) {
  if (!heading.id) {
    heading.id = 'maniaru-heading-' + (index + 1);
  }
});

すでにIDがある見出しはそのまま使い、ない場合だけ上から連番を付けます。H2とH3を別々に集めず、本文に現れる順番のまま一つの配列へ入れる点が重要でした。

スクロール位置から現在の見出しを判定する

判定では、最初の見出しを初期値にしたうえで、すべての見出しを上から確認します。

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);
}

getBoundingClientRect().topは、見出し上端が画面上端から何pxの位置にあるかを返します。MANIARUでは112pxを基準にし、その位置を通過した見出しのうち、最後のものを現在位置にしています。

H2かH3かで判定を分けていません。本文に並ぶ順番どおりに調べるため、H2の下にH3が続く場合も、読んでいる位置に近い見出しへ自然に切り替わります。

active classを切り替える

現在の見出しが決まったら、同じIDを指す目次リンクだけにis-activeを付けます。

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

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

classList.toggleの第2引数へ真偽値を渡すと、条件がtrueのリンクにはクラスを付け、falseのリンクからは外せます。別のリンクへ付け替える処理を分けず、全項目を同じ条件で更新できます。

MANIARUにはPC用とモバイル用の目次があります。同じ見出しを指すリンクには同じ状態を反映しますが、画面上で使う目次は表示幅によって一方だけです。それぞれの目次内では、現在の見出しに対応する項目だけがactiveになります。

IntersectionObserverは更新のきっかけに使う

現在の実装は、常時scrollイベントで判定する方式ではありません。IntersectionObserverで各見出しを監視し、見出しと監視範囲の交差状態が変わったときにupdateActive()を呼びます。

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();

Observerが現在の見出しを直接決めるのではなく、更新処理を呼ぶ役割です。呼ばれたupdateActive()が全見出しの位置を確認し、現在位置を一つに決めます。

固定ヘッダーを考えて112pxずらす

判定を画面上端の0pxにすると、固定ヘッダーの後ろへ見出しが入ってからactiveが切り替わります。そこで現在のMANIARUでは、ヘッダーと上側の余白を考えて112pxを使っています。

この値はサイト共通の正解ではありません。ヘッダーの高さや目次の見せ方が変われば、判定位置も合わせて調整する必要があります。

初期表示とページ最下部を補正する

Observerだけに任せると、ページを開いた直後や、読み込み済みの途中位置へ戻った直後に表示が決まるまで間ができる可能性があります。そこで、スクリプト実行時とload時にもupdateActive()を呼んでいます。

また、最後の見出しより後ろに文章が続く場合、最下部へ到達しても最後の見出しが監視範囲を十分に通過しないことがあります。現在の実装では、画面下端がドキュメント末尾から2px以内へ来たとき、最後の見出しを明示的に選びます。

完成コードの考え方

実装の流れを整理すると、次の順番になります。

  1. 本文からH2/H3を順番どおり取得する
  2. 見出しへIDを用意する
  3. 同じIDを指す目次リンクを作る
  4. 112pxの基準位置を通過した最後の見出しを探す
  5. 一致するリンクだけへis-activeを付ける
  6. Observer、初期表示、load、ページ最下部で更新する

この記事のコードはactive判定を理解しやすくするため、MANIARU固有のページ判定、目次DOMの生成、PCとモバイルの表示制御を省いています。実際に使う場合は、先に見出しと同じIDを指す目次リンクを用意する必要があります。

WordPressで使った結果

現在のMANIARUでは、AI / WEB / CODE / BUILD / GROW / MONEYの通常記事に同じ処理を使っています。記事本文から目次を組み立てるため、記事ごとにJavaScriptへ見出し名を書く必要はありません。

PCでは右側の追従目次、モバイルでは記事冒頭の折りたたみ目次に同じ現在位置を反映します。本文のH2/H3が増えても、取得、ID付与、リンク照合の流れは変わりません。

stickyとactive判定は役割が違う

position: stickyは、目次を画面内へ追従させるCSSです。スクロール位置から現在の見出しを判断することはできません。

今回のJavaScriptは、どの目次項目をactiveにするかを決めます。追従と現在位置表示は別の役割として作り、組み合わせて使っています。

追従部分の実装は、CSSのposition: stickyで追従サイドナビを作る。実装して分かった注意点にまとめています。

実装して分かったこと

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

一方で、見出しを検出するだけでは完成しません。固定ヘッダーの高さ、初期表示、ページ最下部、H2/H3の順番まで含めて初めて、読んでいる位置と表示が自然につながります。

MANIARUでは、Observerを更新のきっかけにし、見出しの位置を同じ関数で判定する形にしました。唯一の実装方法ではありませんが、目次生成とactive切替の役割を分けて確認しやすい構成になっています。

関連記事