会員ログイン
DOM・イベント

JavaScriptでスクロール連動する目次を実装する方法|現在の見出しを色で表示

記事本文の見出しとサイドバー目次がスクロールに連動し、現在位置が青く切り替わる仕組みを示したアイキャッチ画像
はじめに
概要

この記事では、WebCantaの記事詳細ページで使っているコードをもとに、スクロール位置に応じてサイドバー目次の現在の見出しを色で示す方法を解説します。本文のh2h3から目次を作る処理、is-currentの切り替え、requestAnimationFrame()による負荷対策、aria-currentposition: stickyまで、HTML・JavaScript・CSSの役割を順番に整理します。

※本記事の情報とコードは2026年8月時点のWebCantaのローカル環境をもとにしています。テーマ構成や固定ヘッダーの高さが異なるサイトでは、セレクターや余白の調整が必要です。
疑問を持つ読者のイラスト読者

記事をスクロールすると、サイドバー目次の色が見出しに合わせて変わります。CSSだけで現在位置を判断しているのでしょうか?

著者・木村のプロフィール写真木村

現在位置の判定はJavaScriptが担当します。スクロール位置を調べ、対応する目次リンクへis-currentを付けます。CSSは、そのクラスが付いたリンクの文字色と背景色を変えています。

話を聞く読者のイラスト読者

目次のHTMLは、記事ごとに手作業で書いているのですか?

著者・木村のプロフィール写真木村

手作業ではありません。記事本文のh2h3をJavaScriptで取得し、本文上部とサイドバーの2か所へ同じ構造の目次を自動生成しています。

疑問を持つ読者のイラスト読者

スクロールイベントは何度も発生すると聞きました。記事が重くならないか心配です。

著者・木村のプロフィール写真木村

requestAnimationFrame()tickingフラグを使い、1画面描画につき最大1回だけ判定します。この記事では、HTMLの準備から負荷対策、アクセシビリティまで実際のコードに沿って確認します。

スクロール連動目次の仕組み

WebCantaのスクロール連動目次は、HTML、JavaScript、CSSの3つを分担させています。JavaScriptが現在の見出しを判定し、CSSが該当リンクの見た目を変える構成です。

完成形は目次リンクのクラス切り替え

スクロール時に変更しているのは、目次リンクへ付けるクラスです。JavaScriptが現在位置に対応するリンクへis-currentを付け、別の見出しへ移ったら前のリンクから外します。色をJavaScriptへ直接書かず、状態だけをクラスで伝えるため、デザインはCSS側で管理できます。

<a class="article-toc__link" href="#ttl-2">
  実装の流れ
</a>

<!-- 現在位置になった後 -->
<a
  class="article-toc__link is-current"
  href="#ttl-2"
  aria-current="location"
>
  実装の流れ
</a>

WordPress標準・プラグイン・テーマ独自の役割

WordPress標準は、投稿本文をthe_content()で出力します。Table of Contents Plusは本文中のから通常の目次を生成します。WebCantaテーマは、その本文見出しを読み取り、本文上部とサイドバーへ独自目次を生成します。

担当役割主な出力
WordPress標準投稿本文を表示.entry-content内のh2h3
Table of Contents PlusJavaScriptが使えない場合の目次を残す#toc_container
テーマ独自JavaScript独自目次の生成と現在位置判定.article-toc__linkis-current
テーマ独自CSS追従、スクロール領域、現在位置の色position: sticky、背景色

処理の流れを先に確認する

  1. 本文のh2h3を取得する
  2. 見出しIDを確認し、必要なら自動で補う
  3. 本文上部とサイドバーへ目次リンクを生成する
  4. スクロール位置から現在の見出しを判定する
  5. 対応リンクへis-currentaria-currentを付ける
  6. CSSで文字色と背景色を変更する

目次の表示場所をHTMLで用意する

最初に、JavaScriptが目次を挿入する空のリストを用意します。WebCantaでは本文上部をsingle.php、サイドバーをsidebar.phpへ分けています。

本文上部の目次をsingle.phpへ置く

本文上部の目次は、アイキャッチ画像と投稿本文の間に配置します。data-article-tocが目次全体、data-article-toc-listがリンクの挿入先です。

<?php
// 記事上部へJavaScript生成の目次を表示する。
?>
<section
  class="article-toc article-toc--inline"
  data-article-toc
  hidden
>
  <h2>目次</h2>
  <nav aria-label="記事内目次">
    <ol
      class="article-toc__list"
      data-article-toc-list
    ></ol>
  </nav>
</section>

サイドバーの追従目次をsidebar.phpへ置く

記事詳細ページだけにサイドバー目次を出します。本文上部と同じデータ属性を持たせることで、1つのJavaScriptから両方へ同じリンクを生成できます。

<?php if ( is_singular( 'post' ) ) : ?>
  <section
    class="sidebar__section article-toc"
    data-article-toc
    hidden
  >
    <h2>目次</h2>
    <nav aria-label="追従目次">
      <ol
        class="article-toc__list"
        data-article-toc-list
      ></ol>
    </nav>
  </section>
<?php endif; ?>

hiddenで空の目次が見えるのを防ぐ

初期HTMLではhiddenを付けます。JavaScriptが見出しを取得し、目次の生成に成功した後でhiddenを解除します。これにより、読み込み途中や見出しがない記事で空の枠だけが表示されません。

WebCantaではarticle.jsを投稿詳細ページだけで読み込みます。テーマへ導入するときは、サイト全体へ無条件に読み込まず、is_singular( 'post' )などで対象を限定すると管理しやすくなります。

記事本文のh2・h3から目次を生成する

HTMLの準備ができたら、投稿本文から見出しを取得します。目次プラグイン自身の見出しや会員向け案内の見出しは対象から外し、記事本文の構造だけを使います。

目次に使う見出しを取得する

.entry-contentの中からh2h3を取得します。filter()では、Table of Contents Plusの目次内や会員案内内の見出し、空の見出しを除外しています。

// 記事本文の見出しから目次対象を取得する。
const content = document.querySelector('.entry-content');
const tocs = Array.from(
  document.querySelectorAll('[data-article-toc]')
);

if (!content || !tocs.length) return;

const headings = Array.from(
  content.querySelectorAll('h2, h3')
).filter((heading) => {
  return !heading.closest(
    '#toc_container, .toc_container, .members-only-content-notice'
  ) && heading.textContent.trim();
});

if (!headings.length) return;

見出しIDを確認して不足分を補う

目次リンクは#ttl-3のような見出しIDへ移動します。そのため、IDが空、HTMLで使えない形式、重複している場合はsection-*形式のIDを付けます。既に正しいIDがあれば、その値を維持します。

// 見出しリンクに使える一意なIDを保証する。
const usedIds = new Set();
let sectionNumber = 0;
let subsectionNumber = 0;

headings.forEach((heading) => {
  if (heading.tagName === 'H2') {
    sectionNumber += 1;
    subsectionNumber = 0;
  } else {
    subsectionNumber += 1;
  }

  const existingId = heading.id.trim();
  const isUsableId =
    /^[A-Za-z][A-Za-z0-9_.:-]*$/.test(existingId) &&
    !usedIds.has(existingId);

  const generatedId = heading.tagName === 'H2'
    ? `section-${sectionNumber}`
    : `section-${Math.max(sectionNumber, 1)}-${subsectionNumber}`;

  heading.id = isUsableId ? existingId : generatedId;
  usedIds.add(heading.id);
});

H2の配下へH3を入れ子にする

目次ではH2を章、H3を章の中の項目として表示します。H2を追加したときにparentItemへ保存し、次のH2が現れるまでのH3を.article-toc__childrenへ追加します。

// H2を親、H3を子にして目次構造を作る。
tocs.forEach((toc) => {
  const tocList = toc.querySelector('[data-article-toc-list]');
  if (!tocList) return;

  let parentItem = null;

  headings.forEach((heading) => {
    const level = heading.tagName === 'H2' ? 2 : 3;
    const item = document.createElement('li');
    const link = document.createElement('a');

    item.className =
      `article-toc__item article-toc__item--level-${level}`;
    link.className = 'article-toc__link';
    link.href = `#${heading.id}`;
    link.textContent = heading.textContent.trim();
    link.dataset.tocTarget = heading.id;
    item.append(link);

    if (level === 2) {
      tocList.append(item);
      parentItem = item;
      return;
    }

    if (!parentItem) {
      tocList.append(item);
      return;
    }

    let children = parentItem.querySelector(
      ':scope > .article-toc__children'
    );

    if (!children) {
      children = document.createElement('ol');
      children.className = 'article-toc__children';
      parentItem.append(children);
    }

    children.append(item);
  });

  toc.hidden = false;
});

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

目次を作った後は、どの見出しまで読み進めたかを判定します。WebCantaではIntersectionObserverではなく、見出しの画面内位置をgetBoundingClientRect()で調べています。

判定基準をscroll-margin-topに合わせる

固定ヘッダーがあるサイトでは、見出しが画面最上部まで来る前にアンカー位置を止めます。WebCantaはCSSのscroll-margin-topをJavaScriptでも読み取り、アンカー移動と現在位置判定に同じ基準位置を使います

/* 記事見出しを固定ヘッダーの下で停止させる。 */
.entry-content h2,
.entry-content h3 {
  scroll-margin-top: calc(
    var(--site-header-height) + var(--anchor-gap)
  );
}

JavaScript側ではgetComputedStyle()から同じ値を取得します。ヘッダーの高さを変更した場合も、CSSの値を直せば判定位置へ反映されます。

最後に基準位置を通過した見出しを選ぶ

getBoundingClientRect().topは、見出し上端と画面上端の距離です。全見出しを上から確認し、判定位置より上へ進んだ見出しでcurrentを更新します。最後に条件を満たした見出しが現在位置になります。

// 判定位置を通過した最後の見出しを現在位置にする。
const updateCurrent = () => {
  const offset = Number.parseFloat(
    window.getComputedStyle(headings[0]).scrollMarginTop
  ) || 0;

  let current = headings[0];

  headings.forEach((heading) => {
    if (heading.getBoundingClientRect().top <= offset) {
      current = heading;
    }
  });

  setCurrent(current.id);
  ticking = false;
};

is-currentとaria-currentを切り替える

現在の見出しIDと各リンクのdata-toc-targetを比較します。一致するリンクだけにis-currentを付けます。本文上部とサイドバーの両方に同じリンクがあるため、どちらも同時に更新されます。

// 現在位置のリンクへ状態クラスとアクセシビリティ属性を付ける。
const links = tocs.flatMap((toc) =>
  Array.from(toc.querySelectorAll('[data-toc-target]'))
);

const setCurrent = (id) => {
  links.forEach((link) => {
    const isCurrent = link.dataset.tocTarget === id;

    link.classList.toggle('is-current', isCurrent);

    if (isCurrent) {
      link.setAttribute('aria-current', 'location');
    } else {
      link.removeAttribute('aria-current');
    }
  });
};

aria-current="location"は、支援技術へ「現在位置を示すリンク」であることを伝えます。見た目の色だけに頼らず、意味もHTMLへ追加できる点が重要です。

requestAnimationFrameでスクロール処理を軽くする

スクロールイベントは短時間に何度も発生します。毎回すぐ全見出しの位置を計算すると処理が重なるため、画面描画のタイミングへまとめます。

scrollイベントは連続して発生する

マウスホイール、トラックパッド、タッチ操作でページを動かすと、scrollイベントは連続して呼ばれます。イベントごとにDOMの位置を読むと、ブラウザーの描画とJavaScriptの計算が競合しやすくなります。

tickingで1フレーム1回に制限する

tickingtrueの間は次の予約を増やしません。requestAnimationFrame()updateCurrent()を実行した後、falseへ戻して次の判定を受け付けます。スクロール回数ではなく画面描画の単位で処理するのがポイントです。

// スクロール判定を1画面描画につき最大1回へまとめる。
let ticking = false;

window.addEventListener('scroll', () => {
  if (ticking) return;

  ticking = true;
  window.requestAnimationFrame(updateCurrent);
}, { passive: true });

updateCurrent();

passive: trueでスクロールを妨げない

{ passive: true }は、このイベント内でpreventDefault()を使ってスクロールを止めないことをブラウザーへ伝えます。今回の処理は位置を読むだけなので、パッシブリスナーにできます。

最後のupdateCurrent()は初期表示用です。これがないと、最初のスクロールが起きるまで現在位置が設定されません。

CSSで追従と現在位置の色を整える

JavaScriptが状態を付けたら、CSSで目次を追従させ、現在位置を見分けられるデザインにします。長い目次が画面外へはみ出さない設定も必要です。

position: stickyでサイドバー目次を追従させる

position: stickytopで、目次が固定ヘッダーの下へ止まるようにします。目次の高さには上限を設け、リンク一覧だけを縦スクロールできるようにします。

/* 記事詳細の目次を固定ヘッダー下へ追従させる。 */
.article-toc {
  position: sticky;
  top: 10.8rem;
  max-height: calc(100vh - 12.8rem);
  overflow: hidden;
}

.article-toc nav {
  max-height: calc(100vh - 22rem);
  overflow-y: auto;
  overscroll-behavior: contain;
}

長い目次のスクロール領域については、WordPressで長い目次とハンバーガーメニューをスクロールさせる方法でも詳しく解説しています。

is-currentへ青文字と薄い背景を指定する

通常の目次リンクは落ち着いた文字色にし、現在位置だけを主色と薄い背景で強調します。transitionを指定すると、見出しが切り替わるときに色が急変しません。

/* サイドバー目次の通常時と現在位置を見分けやすくする。 */
.article-toc__link {
  display: block;
  padding: 0.5rem 0.7rem;
  color: var(--color-muted);
  text-decoration: none;
  border-radius: 0.6rem;
  transition:
    color 0.18s ease,
    background-color 0.18s ease;
}

.article-toc__link:hover,
.sidebar .article-toc__link.is-current {
  color: var(--color-primary);
  background: #edf6ff;
}

.sidebarを含むセレクターにすることで、現在位置の背景色はサイドバーだけへ適用されます。本文上部の目次にもis-currentは付きますが、常時表示される一覧の見た目は変わりません。

スマートフォンではサイドバー目次を非表示にする

WebCantaではスマートフォンでサイドバー目次を非表示にし、768px以上で表示します。小さい画面では本文上部の目次を使い、PCでは追従目次も使える役割分担です。

.sidebar .article-toc {
  display: none;
}

@media (min-width: 768px) {
  .sidebar .article-toc {
    display: block;
  }
}

動かないときの原因と確認方法

スクロール連動目次が動かない場合は、見出しID、目次リンク、CSSの追従条件、JavaScriptが使えない場合の表示を分けて確認します。

見出しIDとdata-toc-targetを確認する

目次リンクのhrefdata-toc-targetは、対応する見出しIDと一致する必要があります。ブラウザーの開発者ツールで、次の3点を確認します。

  • 本文の見出しに一意なidがある
  • 目次リンクのhrefが同じIDを指している
  • スクロール後に対象リンクへis-currentが付く

クラスの切り替え自体を学びたい場合は、JavaScriptでできることとは?activeクラスでクリック・スクロールアニメーションを実装する方法も参考になります。

stickyが効かない祖先要素を確認する

position: stickyは、祖先要素のoverflowや高さ、Flexbox内の伸び方によって期待どおりに動かないことがあります。目次だけでなく、サイドバーとレイアウト全体のCSSも確認してください。

追従が途中で止まる仕組みと確認点は、CSSのposition: stickyでサイドバーを追従固定する方法で詳しく説明しています。

JavaScriptが使えない場合の目次を残す

WebCantaは、JavaScriptの目次生成に成功した後でTable of Contents Plusの#toc_containertoc-fallback--enhancedを付けて非表示にします。JavaScriptが動かない場合はプラグインの目次が残るため、記事内リンクそのものを失いません。

// 独自目次の生成後だけプラグイン目次を非表示にする。
content.querySelectorAll(
  '#toc_container, .toc_container'
).forEach((fallbackToc) => {
  fallbackToc.classList.add('toc-fallback--enhanced');
  fallbackToc.setAttribute('aria-hidden', 'true');
});

実装後は、初期表示、スクロール中、目次リンクをクリックした直後、ページ末尾、JavaScriptを無効にした状態を確認します。PCだけでなくスマートフォンで本文上部の目次が使えることも確認してください。

まとめ

  • WebCantaは記事本文のh2h3から本文上部とサイドバーの目次を自動生成している
  • 現在位置はgetBoundingClientRect().topscroll-margin-topを比較して判定する
  • 対応リンクへis-currentaria-current="location"を付け、CSSで現在位置を表示する
  • requestAnimationFrame()tickingでスクロール処理を1画面描画につき最大1回へまとめる
  • サイドバー目次はposition: stickyで追従させ、長い場合は内部だけを縦スクロールさせる
  • JavaScriptが使えない場合はTable of Contents Plusの目次をフォールバックとして残す

MEMBER COMMENTS

コメント(0件)

会員ログイン後にコメントできます

記事への質問や感想をコメントで共有できます。無料会員登録後もこの記事へ戻れます。