テクニック

記事の目次を自動生成する実装|見出しから作って追従させる手順

この記事の対象: WordPressまたはShopifyで記事テンプレートを触っている制作者
読了時間: 約12分

記事に目次を入れる方法は、プラグインを入れるか、自分で書くかの二択です。プラグインは手っ取り早い代わりに、マークアップも見た目も向こうの都合で決まります。テンプレートを自分で触れる立場なら、見出しを走査して目次を組み立てる処理を50行ほど書いたほうが早く、後から壊れません。この記事では、本文のH2・H3を拾ってリストを作り、日本語見出しでも安全に飛べるIDを振り、スクロール位置に合わせて現在地をハイライトするところまでを、コピーしてそのまま動く形で書きます。外部ライブラリは使いません。


前提を先に固める

書き始める前に、どこで動かすかを決めておきます。ここが曖昧なまま進むと、コードは合っているのに反映されないという一番つらい時間を過ごします。

共通の前提

項目 内容
使う技術 素のHTML / CSS / JavaScript のみ。jQuery もライブラリも不要
対応ブラウザ Chrome / Edge / Firefox / Safari の現行版。IntersectionObserver は現行の主要ブラウザで利用できる
必要な権限 テンプレートファイルを編集できること(WordPress: 子テーマ、Shopify: テーマコード編集)
動作の場所 ブラウザ側(JavaScript)で組み立てる方式を基本にします

サーバー側(PHP / Liquid)で本文HTMLをパースして目次を出す方法もありますが、この記事はJavaScript側で組み立てる方式を採ります。理由は3つです。

  • 本文の保存形式に依存しない(ブロックエディタでもクラシックでも、Shopifyのリッチテキストでも同じコードが動く)
  • キャッシュプラグインやCDNとぶつからない
  • 見出しが1つも無い記事では目次ブロックごと消える処理を、同じ場所に書ける

SEO上の懸念を先に潰しておくと、目次はページ内リンクであり、クローラが本文を読む妨げにはなりません。ただし目次がJavaScript生成である以上、目次そのものはインデックス対象として当てにしないでください。目次はあくまで読者の移動手段です。

WordPress側の前提

ブロックテーマとクラシックテーマで、置き場所が変わります。

ブロックテーマ クラシックテーマ
本文の出力場所 wp:post-content ブロック single.php の the_content()
ラッパー .entry-content が付くことが多い テーマ次第
JSの読み込み 子テーマの functions.php 同左
目次の設置 投稿本文の先頭にHTMLブロックを1つ置くか、the_content フィルタで自動挿入 同左

どちらでも、有料テーマ・公式テーマの本体ファイルは直接編集しないでください。アップデートで消えます。子テーマを作ってそこに書きます。

Shopify側の前提

Online Store 2.0 のテーマ(sections/ に .liquid があり、テーマエディタでセクションを追加できるもの)を想定します。ブログ記事のテンプレートは sections/main-article.liquid です。ブログ記事本文は article.content として出力され、ここに管理画面のリッチテキストエディタで入れたH2・H3が入ります。

Shopifyのブログまわりの改修全般は、記事一覧側の話もあわせて読むと全体像がつかめます。


目次生成の全体像

処理の流れは4段階です。難しいことはしていません。

目次を作る4つの工程
見出しを拾う本文の中のH2・H3をDOMから取得
IDを振る飛び先になるidを見出しに付与
リストを組む階層付きのul/liを生成して挿入
現在地を出すスクロールに応じて該当項目を強調

前の3つまでで「動く目次」になります。4つ目の追従は体験の話なので、後半で足します。まずは3つ目までを一気通貫で書きます。

なぜプラグインを使わないか

判断材料として、対比だけ置いておきます。

自前実装とプラグインの違い

自前で書く

  • マークアップを自分で決められる
  • 読み込むJSは1ファイル・数KB
  • デザインがテーマと揃う
  • WordPressとShopifyで同じコードが使える

プラグイン

  • 設置は数分で終わる
  • CSSの上書きに手間がかかることがある
  • プラットフォームを移ると作り直し
  • 更新停止のリスクを抱える

記事数が数本で、今後もWordPressから動かないなら、プラグインで十分です。テンプレートを納品する立場だったり、同じ実装を複数案件に横展開するなら、自前のほうが結局は手間が少なく済みます。


実装の核になるJavaScript

実装の核になるJavaScript

まずこれを書きます。プラットフォームを問わず、このファイル1つが本体です。

コード本体

ファイルとして保存します。置き場所は後述しますが、中身は共通です。

/* toc.js — 記事の目次を自動生成する
   本文コンテナ内の h2 / h3 を拾って目次を作り、指定した箱に差し込む */
(function () {
  'use strict';

  // ▼ここは自分のテーマに合わせて変更する
  var CONTENT_SELECTOR = '.entry-content';   // 本文のラッパー
  var TOC_SELECTOR     = '#js-toc';          // 目次を挿入する箱
  var HEADING_SELECTOR = 'h2, h3';           // 拾う見出し(h4まで拾うなら 'h2, h3, h4')
  var MIN_HEADINGS     = 2;                  // これ未満なら目次を出さない
  // ▲変更ここまで

  function slugify(text, index) {
    var base = text
      .trim()
      .toLowerCase()
      .replace(/\s+/g, '-')
      .replace(/[^\w\u3040-\u30ff\u3400-\u9fff\u4e00-\u9faf-]/g, '');
    // 日本語だけの見出しはそのまま残る。空になった場合は連番で補う
    return base ? 'sec-' + index + '-' + base : 'sec-' + index;
  }

  function init() {
    var content = document.querySelector(CONTENT_SELECTOR);
    var box = document.querySelector(TOC_SELECTOR);
    if (!content || !box) return;

    var headings = content.querySelectorAll(HEADING_SELECTOR);
    if (headings.length < MIN_HEADINGS) {
      box.remove();
      return;
    }

    var used = {};
    var list = document.createElement('ul');
    list.className = 'toc-list';
    var currentSub = null;

    Array.prototype.forEach.call(headings, function (h, i) {
      // すでに手で id が振られていればそれを尊重する
      if (!h.id) {
        var id = slugify(h.textContent, i + 1);
        while (used[id] || document.getElementById(id)) {
          id = id + '-x';
        }
        h.id = id;
      }
      used[h.id] = true;

      var li = document.createElement('li');
      li.className = 'toc-item toc-' + h.tagName.toLowerCase();

      var a = document.createElement('a');
      a.href = '#' + h.id;
      a.textContent = h.textContent.trim();
      a.className = 'toc-link';
      li.appendChild(a);

      if (h.tagName === 'H2') {
        list.appendChild(li);
        currentSub = null;
      } else {
        // H3 は直前の H2 の子として入れ子にする
        var lastLi = list.lastElementChild;
        if (!lastLi) {
          list.appendChild(li);
          return;
        }
        if (!currentSub) {
          currentSub = document.createElement('ul');
          currentSub.className = 'toc-sublist';
          lastLi.appendChild(currentSub);
        }
        currentSub.appendChild(li);
      }
    });

    box.appendChild(list);
    box.hidden = false;
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }
})();

変更すべき箇所

コピーしたあと、必ず自分のテーマに合わせて直す箇所は4つです。ファイル冒頭にまとめてあります。

変数 意味 よくある値
CONTENT_SELECTOR 本文を包んでいる要素 WordPress: .entry-content / Shopify: .article-template__content など
TOC_SELECTOR 目次を差し込む空の箱 #js-toc(自分で置く)
HEADING_SELECTOR 拾う見出しレベル h2, h3。細かい記事なら h2, h3, h4
MIN_HEADINGS 目次を出す最低見出し数 2〜3。少ない記事に目次が付くと邪魔になる

CONTENT_SELECTOR の確認方法は簡単です。記事ページを開いて本文の適当な段落を右クリック→検証、親要素をたどって最初に出てくる意味のありそうなクラス名を使います。ここを間違えるとサイドバーやフッターの見出しまで拾ってしまうので、慎重に選んでください。

何をしているか

処理の要点だけ補足します。

  • querySelectorAll は書いた順に返す — DOM順で返る仕様なので、記事の並びどおりに目次が組めます。ソートは不要です。
  • 既存のidを壊さない — if (!h.id) で、手動で振ったidや他のプラグインが振ったidをそのまま使います。これを入れておかないと、既存のアンカーリンクが全部死にます。
  • 重複時は -x を足す — 同じ文言の見出しが2回出てくる記事は珍しくありません。idが重複するとブラウザは最初のものにしか飛べないので、後から出たほうをずらします。
  • 見出しが少なければ箱ごと消す — box.remove() で、空の枠線だけが残る事故を防ぎます。

H2が無く、H3から始まる記事の扱い

意外と踏むのがこのケースです。編集の都合でH3から書き始めた記事では、最初のH3にとって「直前のH2」が存在しません。上のコードは list.lastElementChild が空のときにH3をトップレベルへ直に置く分岐を持っているので、目次が空になったり例外で止まったりはしません。ただし、その記事だけ階層が崩れて見えます。

構造としては見出しレベルを飛ばさないほうが正しいので、恒久的な対処は記事側の見出しを直すことです。どうしてもテンプレート側で吸収したいなら、HEADING_SELECTOR を 'h2' だけに絞って、H3を目次に載せない運用のほうが破綻しません。目次の階層を無理に補正するコードを足すと、記事ごとの例外処理が増えていきます。


日本語見出しをアンカーにするときの注意

ここが一番つまずくところです。日本語の見出しをidにする場合、3つの罠があります。

罠1:URLに日本語が入る

id="デザインの基本" は、HTML5では仕様上は有効です。ブラウザも飛べます。ただしURLに載ると #%E3%83%87%E3%82%B6%E3%82%A4%E3%83%B3... のようにパーセントエンコードされ、SNSやチャットに貼ったときに読めない長い文字列になります。

上のコードでは 'sec-' + index + '-' という接頭辞を先に付けています。日本語部分は残りますが、少なくとも先頭が英数字になるので、CSSセレクタでも扱いやすくなります。日本語を一切残したくない場合は、slugify を次のように差し替えてください。

function slugify(text, index) {
  // 日本語は捨てて連番のみにする版
  var ascii = text.trim().toLowerCase()
    .replace(/[^a-z0-9\s-]/g, '')
    .replace(/\s+/g, '-')
    .replace(/^-+|-+$/g, '');
  return ascii ? 'sec-' + index + '-' + ascii : 'sec-' + index;
}

こちらは常に sec-1 sec-2-about のような形になります。安全ですが、URLを見ても内容が分からなくなるトレードオフがあります。記事URLをシェアされる前提のメディアなら、連番だけのほうが無難です。

罠2:CSSセレクタで数字始まりが使えない

id="2026-trend" のように数字から始まるidは、HTMLとしては有効ですが、CSSの #2026-trend は無効です(エスケープが必要になります)。見出しに年号が入る記事は多いので、これは現実的に踏みます。上のコードが sec- を前置しているのは、この回避も兼ねています。

罠3:id重複でアンカーが効かない

WordPressのブロックエディタは、見出しブロックに手動でHTMLアンカーを設定できます(ブロック設定 → 高度な設定 → HTMLアンカー)。ここで設定したidと、JS側が生成したidがぶつかることがあります。前述の document.getElementById(id) チェックが効いているので衝突は避けられますが、手動アンカーを使っているサイトでは、その記事だけ挙動を確認してください。

動作確認の手順

3つとも、ブラウザのコンソールで確認できます。

// 記事ページのコンソールに貼って実行する
document.querySelectorAll('.entry-content h2, .entry-content h3')
  .forEach(function (h) { console.log(h.tagName, h.id, h.textContent.trim()); });

idが空欄のものがあれば生成に失敗しています。同じidが2回出てきたら重複です。目視で1分で終わります。


WordPressへの設置手順

子テーマ前提で書きます。子テーマがまだ無い場合は、テーマフォルダの隣に your-theme-child/ を作り、style.css と functions.php を置くところから始めてください。

1. ファイルを置く

wp-content/themes/your-theme-child/
├── functions.php
├── style.css
└── assets/
    ├── js/
    │   └── toc.js      ← さっきのJavaScript
    └── css/
        └── toc.css     ← 後述のCSS

2. 読み込む

functions.php の末尾に追記します。

<?php
/**
 * 目次用のCSS / JS を記事ページだけで読み込む
 * wp-content/themes/your-theme-child/functions.php に追記
 */
function mytheme_enqueue_toc_assets() {
    // 単一投稿ページ以外では読まない(固定ページにも出すなら is_page() を足す)
    if ( ! is_single() ) {
        return;
    }

    $dir = get_stylesheet_directory_uri();
    $ver = wp_get_theme()->get( 'Version' );

    wp_enqueue_style(
        'mytheme-toc',
        $dir . '/assets/css/toc.css',
        array(),
        $ver
    );

    wp_enqueue_script(
        'mytheme-toc',
        $dir . '/assets/js/toc.js',
        array(),
        $ver,
        true // フッターで読む
    );
}
add_action( 'wp_enqueue_scripts', 'mytheme_enqueue_toc_assets' );

is_single() を is_singular( array( 'post', 'column' ) ) のように書き換えれば、カスタム投稿タイプにも出せます。

3. 目次の箱を本文に入れる

手動で入れる方法と、自動で挿入する方法があります。

手動:投稿編集画面で本文の先頭にカスタムHTMLブロックを追加し、次を貼ります。

<div id="js-toc" class="toc" hidden>
  <p class="toc-title">目次</p>
</div>

自動:毎回貼るのが面倒なら、the_content フィルタで最初のH2の直前に差し込みます。functions.php に追記してください。

<?php
/**
 * 本文の最初の h2 の直前に目次の箱を挿入する
 * wp-content/themes/your-theme-child/functions.php に追記
 */
function mytheme_insert_toc_box( $content ) {
    if ( ! is_single() || ! is_main_query() || ! in_the_loop() ) {
        return $content;
    }
    // すでに手で置いてある記事はそのまま
    if ( strpos( $content, 'id="js-toc"' ) !== false ) {
        return $content;
    }

    $box = '<div id="js-toc" class="toc" hidden><p class="toc-title">目次</p></div>';

    // 最初の <h2 を探して、その前に差し込む
    $pos = strpos( $content, '<h2' );
    if ( $pos === false ) {
        return $content;
    }

    return substr( $content, 0, $pos ) . $box . substr( $content, $pos );
}
add_filter( 'the_content', 'mytheme_insert_toc_box' );

in_the_loop() と is_main_query() を入れているのは、関連記事のループや抜粋生成でこのフィルタが二重に走るのを防ぐためです。ここを省くと、トップページのカードにまで目次の箱が生えます。

なお hidden 属性を付けているのは、JSが動く前の一瞬「目次」という見出しだけが表示されるのを防ぐためです。JS側の box.hidden = false; で外れます。

4. 挿入位置を「リード文の後」にしたい場合

最初のH2の直前は、多くの記事でリード文の直後になります。ただしリード文が長い記事では、目次が画面のかなり下に落ちます。ファーストビューに近い位置へ出したいなら、最初の </p> の直後に差し込む形へ変えます。

// 最初の段落の直後に差し込む版(上の $pos の算出だけ差し替える)
$end = strpos( $content, '</p>' );
if ( $end === false ) {
    return $content;
}
$pos = $end + strlen( '</p>' );

どちらが良いかは記事の型で変わります。リード文が2〜3行で済む解説記事なら段落直後、導入で背景から説明する記事なら最初のH2直前のほうが読者の流れを切りません。両方試して、記事一覧の中で見え方が揃うほうを採用してください。

WordPressで似た構造の実装をもう1つ入れるなら、ブログカードの自作も同じ考え方で書けます。


Shopifyへの設置手順

Shopifyへの設置手順

Online Store 2.0 のテーマを想定します。管理画面 → オンラインストア → テーマ → コードを編集 から進めます。

1. JSとCSSをアセットに置く

assets/ に2ファイル追加します。

assets/
├── toc.js     ← さっきのJavaScript(CONTENT_SELECTOR を書き換える)
└── toc.css    ← 後述のCSS

Shopifyの場合、CONTENT_SELECTOR はテーマによって変わります。.article-template__content が入っていることが多いですが、必ず自分のストアの記事ページで検証して確かめてください。テーマのバージョンによってクラス名が変わります。

var CONTENT_SELECTOR = '.article-template__content';  // ← 検証で確かめた値に

2. main-article.liquid に組み込む

sections/main-article.liquid を開き、記事本文を出力している {{ article.content }} を探します。その直前に目次の箱を、ファイルの適切な位置にアセット読み込みを足します。

{{ 'toc.css' | asset_url | stylesheet_tag }}

{%- if section.settings.show_toc -%}
  <div id="js-toc" class="toc" hidden>
    <p class="toc-title">{{ section.settings.toc_title | escape }}</p>
  </div>
{%- endif -%}

{{ article.content }}

{%- if section.settings.show_toc -%}
  <script src="{{ 'toc.js' | asset_url }}" defer="defer"></script>
{%- endif -%}

{{ article.content }} の行は既存のものなので、新たに書き足さないでください。上下を挟む形にします。

3. テーマエディタから切り替えられるようにする

sections/main-article.liquid の末尾、{% schema %} ブロック内の "settings": [ 配列に2つ追加します。

{
  "type": "checkbox",
  "id": "show_toc",
  "label": "目次を表示する",
  "default": true
},
{
  "type": "text",
  "id": "toc_title",
  "label": "目次の見出し",
  "default": "目次"
}

JSONなので、直前の項目の末尾にカンマが必要です。ここを忘れるとテーマエディタが「セクションを読み込めません」というエラーを出します。保存前にカンマの位置だけ見直してください。

これで、記事テンプレートのテーマエディタから目次のオン・オフができるようになります。クライアントに納品するなら、この一手間は入れておいたほうが後の問い合わせが減ります。

4. 記事ごとに出し分けたい場合

テーマエディタの設定はブログ記事テンプレート全体にかかるので、「この記事だけ目次を消す」ができません。記事単位で切りたいなら、記事タグかメタフィールドで判定を足します。手軽なのはタグです。

{%- assign show_toc = section.settings.show_toc -%}
{%- if article.tags contains 'no-toc' -%}
  {%- assign show_toc = false -%}
{%- endif -%}

{%- if show_toc -%}
  <div id="js-toc" class="toc" hidden>
    <p class="toc-title">{{ section.settings.toc_title | escape }}</p>
  </div>
{%- endif -%}

no-toc タグを付けた記事だけ目次が消えます。タグは記事一覧の絞り込みにも使われるので、運用でタグを表に出しているストアではメタフィールド(article.metafields.custom.hide_toc)に寄せたほうが安全です。どちらにしても、判定を1か所の assign に集めておくと後で切り替えやすくなります。

Shopifyのセクション実装をライブラリなしで組む考え方は、スライダーの回でも同じ方針を採っています。


CSSで見た目を整える

CSSで見た目を整える

WordPress・Shopify共通で使えます。toc.css として保存してください。

/* toc.css — 記事の目次 */

/* ▼ここは自分のテーマに合わせて変更する */
:root {
  --toc-accent: #2b6cb0;   /* リンク色・現在地の色 */
  --toc-border: #e2e8f0;   /* 枠線 */
  --toc-bg: #f7fafc;       /* 背景 */
}
/* ▲変更ここまで */

.toc {
  margin: 2.5rem 0;
  padding: 1.25rem 1.5rem;
  background: var(--toc-bg);
  border: 1px solid var(--toc-border);
  border-radius: 8px;
}

.toc[hidden] {
  display: none;
}

.toc-title {
  margin: 0 0 0.75rem;
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}

.toc-list,
.toc-sublist {
  margin: 0;
  padding: 0;
  list-style: none;
}

.toc-list > .toc-item {
  margin-top: 0.5rem;
}

.toc-list > .toc-item:first-child {
  margin-top: 0;
}

.toc-sublist {
  margin: 0.4rem 0 0 1.1rem;
  padding-left: 0.9rem;
  border-left: 1px solid var(--toc-border);
}

.toc-sublist > .toc-item {
  margin-top: 0.35rem;
}

.toc-link {
  display: block;
  color: inherit;
  text-decoration: none;
  line-height: 1.6;
  padding: 0.1rem 0;
  transition: color 0.15s ease;
}

.toc-link:hover,
.toc-link:focus-visible {
  color: var(--toc-accent);
  text-decoration: underline;
}

.toc-h2 > .toc-link {
  font-weight: 600;
}

.toc-h3 > .toc-link {
  font-size: 0.925rem;
  opacity: 0.85;
}

/* 現在地のハイライト(後述のJSが付与する) */
.toc-link.is-current {
  color: var(--toc-accent);
  font-weight: 700;
}

/* 見出しへ飛んだとき、固定ヘッダーの下に隠れないようにする */
:target,
.entry-content h2,
.entry-content h3 {
  scroll-margin-top: 80px;   /* ← 固定ヘッダーの高さに合わせて変更する */
}

/* アンカー移動を滑らかにする。設定で減らしている人には効かせない */
@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

変更すべき箇所

  • --toc-accent / --toc-border / --toc-bg の3色をサイトの配色に合わせます
  • scroll-margin-top: 80px を、固定ヘッダーの実際の高さに変えます。ヘッダーが無いサイトは 1rem 程度で構いません
  • .entry-content の部分を、WordPress以外なら自分の CONTENT_SELECTOR に合わせます

scroll-margin-top は地味ですが効きます。これが無いと、目次から飛んだ先で見出しが固定ヘッダーの裏に隠れ、本文の途中から読まされることになります。目次を実装したのに評判が悪いサイトは、だいたいこれが原因です。 現行の主要ブラウザではそのまま使えるプロパティなので、フォールバックを別に用意する必要はありません。

ヘッダーの高さが画面幅で変わるサイトなら、値をCSS変数にしておくと後の調整が1か所で済みます。

:root { --header-h: 56px; }
@media (min-width: 768px) { :root { --header-h: 80px; } }

:target,
.entry-content h2,
.entry-content h3 {
  scroll-margin-top: calc(var(--header-h) + 16px);
}

スクロールに合わせて現在地を示す

スクロールに合わせて現在地を示す

ここからは体験の追加です。読んでいる位置の見出しを、目次側でハイライトします。

IntersectionObserver で書く

scroll イベントで毎回位置を計算する方法もありますが、発火回数が多く、スロットリングの実装も必要になります。IntersectionObserver なら数行で済み、ブラウザ側で最適化されます。

toc.js の init() 関数の末尾、box.hidden = false; の直後に次を追記します。

    // ここから追記:現在地のハイライト
    setupScrollSpy(headings, box);
  }

  function setupScrollSpy(headings, box) {
    // IntersectionObserver 非対応なら何もしない(目次自体は動く)
    if (!('IntersectionObserver' in window)) return;

    var links = {};
    Array.prototype.forEach.call(box.querySelectorAll('.toc-link'), function (a) {
      links[a.getAttribute('href').slice(1)] = a;
    });

    var visible = [];

    function render() {
      var id = visible.length ? visible[0] : null;
      Object.keys(links).forEach(function (key) {
        links[key].classList.toggle('is-current', key === id);
      });
    }

    var observer = new IntersectionObserver(function (entries) {
      entries.forEach(function (entry) {
        var id = entry.target.id;
        var pos = visible.indexOf(id);
        if (entry.isIntersecting) {
          if (pos === -1) visible.push(id);
        } else {
          if (pos !== -1) visible.splice(pos, 1);
        }
      });
      // DOM順に並べ直して、一番上にあるものを現在地とする
      visible.sort(function (a, b) {
        var ea = document.getElementById(a);
        var eb = document.getElementById(b);
        if (!ea || !eb) return 0;
        return ea.compareDocumentPosition(eb) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1;
      });
      render();
    }, {
      // ▼ここを変更する:画面上部を基準線にする
      rootMargin: '-80px 0px -70% 0px',
      threshold: 0
    });

    Array.prototype.forEach.call(headings, function (h) {
      observer.observe(h);
    });
  }

追記した結果、init() の閉じ括弧の位置が変わります。元の init() の最後の } を、追記した setupScrollSpy(headings, box); の後ろに移す形になっている点を確認してください。

rootMargin の意味と調整

このコードで唯一チューニングが要るのは rootMargin です。

rootMargin: '-80px 0px -70% 0px'
             ↑上    ↑右  ↑下    ↑左
  • 上の -80px — 固定ヘッダーの高さに合わせます。CSSの scroll-margin-top と同じ値にすると挙動が揃います
  • 下の -70% — 画面の下から70%を判定対象から外し、上部30%だけを「今読んでいる領域」とみなします。この値が体感を決めます。ハイライトが早く進みすぎると感じたら -80% に、遅いと感じたら -50% に調整してください

数字を1つ変えて記事をスクロールする、を3回もやれば納得のいく値が見つかります。

目次が長いときの追従

見出しが15個を超えるような記事では、目次自体をスクロール追従させたくなります。サイドバーがあるレイアウトなら、CSSだけで済みます。

/* サイドバーに置いた目次を追従させる(サイドバーがある場合のみ) */
@media (min-width: 1024px) {
  .toc.is-sticky {
    position: sticky;
    top: 100px;                       /* ← ヘッダー高さ + 余白に変更 */
    max-height: calc(100vh - 140px);  /* ← 上の値に合わせて調整 */
    overflow-y: auto;
    overscroll-behavior: contain;
  }
}

position: sticky が効かないときは、親要素に overflow: hidden が付いていないかを疑ってください。実務ではこれが原因の大半です。親のどこかに overflow があると sticky は無効になります。

モバイルでは追従させないほうが読みやすいので、min-width: 1024px のメディアクエリで囲っています。狭い画面で画面上部を目次が占有すると、本文を読む面積が減るだけです。

追従中の目次で現在地が枠の外に出るとき

目次自体をスクロール可能にすると、記事の後半で is-current が付いた項目が目次の見えている範囲から外れます。ハイライトはされているのに、読者からは見えません。render() の中で1行足すと解決します。

    function render() {
      var id = visible.length ? visible[0] : null;
      Object.keys(links).forEach(function (key) {
        var on = key === id;
        links[key].classList.toggle('is-current', on);
        if (on && box.scrollHeight > box.clientHeight) {
          // 目次の枠内でだけスクロールさせる(ページ全体は動かさない)
          var top = links[key].offsetTop - box.clientHeight / 2;
          box.scrollTop = top > 0 ? top : 0;
        }
      });
    }

scrollIntoView() を使うとページ全体が動いてしまうことがあるので、scrollTop を直接触っています。目次が枠内に収まっているときは scrollHeight > clientHeight が偽になるので、この処理は走りません。


アクセシビリティと計測

アクセシビリティと計測

最低限やっておくこと

目次はナビゲーションなので、支援技術に対して役割を伝えておきます。箱のHTMLを次のように変えるだけです。

<nav id="js-toc" class="toc" aria-labelledby="toc-title" hidden>
  <p class="toc-title" id="toc-title">目次</p>
</nav>

div を nav に変え、aria-labelledby で見出しと紐付けます。CSSもJSも変更不要です。スクリーンリーダーで「目次、ナビゲーション」と読み上げられるようになります。

キーボード操作については、生成しているのが普通の <a href="#..."> なので、Tabキーでの移動もEnterでの移動も最初から動きます。特別な処理は要りません。CSS側で :focus-visible のスタイルを当てているのは、キーボード操作時にどこにいるか分かるようにするためです。

もう1点、is-current は色と太字だけで示しているので、色を判別しにくい環境では変化が伝わりません。左に線を足すなど、色以外の手がかりを1つ重ねておくと安全です。

.toc-link.is-current {
  color: var(--toc-accent);
  font-weight: 700;
  box-shadow: inset 3px 0 0 var(--toc-accent);
  padding-left: 0.6rem;
}

目次がどれだけ使われているか測る

入れたら効果を見たくなります。GA4なら、目次リンクのクリックをイベントとして送るのが一番手っ取り早い方法です。toc.js の末尾に追記します。

  // 目次クリックを GA4 に送る(gtag が読み込まれている場合のみ)
  document.addEventListener('click', function (e) {
    var link = e.target.closest('.toc-link');
    if (!link) return;
    if (typeof window.gtag !== 'function') return;
    window.gtag('event', 'toc_click', {
      link_text: link.textContent.trim(),
      link_url: link.getAttribute('href')
    });
  });

gtag が無い環境では黙って何もしないので、タグを入れる前に仕込んでおいても壊れません。イベント名 toc_click は好きに変えてください。

見るべきは「クリック率が高い項目」です。特定のH2にクリックが集まる記事は、その見出しの内容を求めて来た読者が多いということなので、その章を厚くするか、記事を分割して独立させる判断材料になります。逆に目次のクリックがほとんど発生しない記事は、リード文で十分に用が足りているか、そもそも目次が画面の下すぎる位置に出ている可能性があります。

計測の設計そのものをどう組むかは、別記事で扱っています。


うまくいかないとき

つまずきやすい順に並べます。上から確認してください。

目次が何も表示されない

  1. CONTENT_SELECTOR が間違っている — コンソールで document.querySelector('.entry-content') を実行し、null が返るなら不一致です。検証ツールで正しいクラス名を確認してください
  2. TOC_SELECTOR の箱が存在しない — 本文にHTMLブロックを入れ忘れているか、the_content フィルタが効いていません。ページのソースに id="js-toc" があるか確認します
  3. 見出しが MIN_HEADINGS 未満 — 仕様どおりの動作です。テスト用にH2を3つ以上ある記事で試してください
  4. JSが読み込まれていない — Networkタブで toc.js が200で返っているか見ます。404ならパスの誤りです

目次に余計な見出しが混ざる

サイドバー・関連記事・フッターの見出しを拾っています。CONTENT_SELECTOR の範囲が広すぎるので、より内側の要素を指定し直してください。.entry-content の代わりに article .entry-content のように絞ると解決することがあります。

クリックしても飛ばない・行き先がずれる

  • 飛ばない — 見出しにidが振られていません。前述のコンソールコマンドで確認します
  • ヘッダーに隠れる — scroll-margin-top の値が足りていません。実際のヘッダー高さを検証ツールで測って合わせます
  • 数字始まりのidでCSSが効かない — 罠2の話です。sec- の接頭辞が外れていないか確認してください

ハイライトが動かない・変な位置で光る

  • rootMargin の下側の値(-70%)を調整します。まず -50% にして変化を見ると、どちらに寄せるべきか判断しやすくなります
  • 見出しが画面より長い(H2の直下にすぐ次のH2が来るような短いセクション)と、複数が同時に交差してちらつきます。threshold: 0 のまま rootMargin の下側を強めるのが効きます

遅延読み込みの画像でハイライトがずれる

本文に loading="lazy" の画像が多い記事では、画像が読み込まれるたびに下の要素の位置が動きます。IntersectionObserver は位置の変化を自分で追うので破綻はしませんが、スクロール中に判定が一瞬前後することがあります。画像に width / height 属性を入れて領域を先に確保しておけば止まります。これはレイアウトシフトの対策そのものなので、目次のためだけの作業にはなりません。

目次が一瞬ちらついてから表示される

JSがフッターで動く以上、生成のタイミングは本文描画の後になります。hidden を付けているので「目次」の文字だけが先に出る事故は防げますが、箱の高さが後から生えることによるレイアウトの動きは残ります。気になる場合は .toc に min-height を仮置きするか、そもそも目次をサイドバーへ出して本文の流れから外す設計にします。

Shopifyでテーマエディタがエラーを出す

{% schema %} 内のJSONが壊れています。原因の大半はカンマです。追加したブロックの前後で、カンマの数と位置を確認してください。JSONは末尾カンマを許しません。

WordPressで目次が二重に出る

the_content フィルタが複数回走っています。is_main_query() と in_the_loop() のチェックが入っているか、それでも出るならテーマが本文を別の場所でも呼んでいます。手動挿入方式に切り替えるのが確実です。

キャッシュのせいで反映されない

WordPressならキャッシュプラグインの削除、Shopifyならブラウザのハードリロード(Ctrl+Shift+R / Cmd+Shift+R)。wp_enqueue_script の第4引数にテーマバージョンを渡しているのは、この事故を減らすためです。テーマの style.css のバージョンを上げれば、クエリ文字列が変わって再読み込みされます。


納品前に見るところ

クライアント案件で目次を入れたときに、最後に確認する項目を並べます。自分の環境では動いていても、実記事で崩れることがあります。

  • 見出しが1つしか無い記事を1本開いて、目次の箱が消えているか
  • H3から始まる記事を1本開いて、階層が破綻していないか
  • 見出しが20個ある長い記事を開いて、目次が画面を占有しすぎていないか
  • モバイル実機で、目次から飛んだ先が固定ヘッダーに隠れていないか(scroll-margin-top はモバイルのヘッダー高さで別途確認する)
  • 既存のアンカーリンクが張られている記事があれば、そのidが維持されているか
  • 記事の印刷(Ctrl+P)で、目次が邪魔になっていないか。気になるなら @media print { .toc { display: none; } } を足す

この6つを1本ずつ見て回っても15分程度です。公開後に指摘されるより早く済みます。


よくある質問

目次はSEOに効果がありますか

目次そのものが順位を直接押し上げる仕組みは公表されていません。ただし、長い記事で読者が目的の箇所へ移動できることは滞在の質に影響しますし、検索結果にページ内リンクが表示されるケースもあります。順位対策として入れるより、読みやすさのために入れるものと考えるのが妥当です。

JavaScript生成の目次はクローラに読まれますか

主要な検索エンジンはJavaScriptを実行してレンダリングしますが、実行のタイミングは保証されていません。目次はあくまで読者向けの導線として設計し、本文の見出し構造そのものをHTMLに正しく持たせることを優先してください。見出しさえ正しければ、目次の生成方法は評価に影響しません。

見出しレベルはどこまで拾うべきですか

H2とH3の2階層までを基本にしてください。H4まで入れると項目数が一気に増え、目次が本文と同じ長さになります。どうしてもH4が必要なら、HEADING_SELECTOR に足したうえで、CSS側でH4だけ折りたたむ設計を検討してください。

既存のプラグインから乗り換えるとき、既存のアンカーリンクは壊れますか

プラグインが振っていたidと、このコードが生成するidは異なるため、外部から張られていた #toc-1 のようなリンクは切れます。乗り換え前に既存のidを控え、重要なものは見出しブロックのHTMLアンカーに手動で設定しておけば、コードが if (!h.id) で尊重します。

WordPressとShopifyでコードを共通化できますか

toc.js と toc.css はそのまま共通で使えます。差し替えるのは CONTENT_SELECTOR の値と、箱をどこに置くかというテンプレート側の実装だけです。複数案件を持っている場合、この2ファイルを社内の共通アセットとして持っておくと、2件目以降の作業は設定値の書き換えだけで済みます。


まとめ

目次の自動生成は、見出しを拾ってリストを組むだけの処理です。難しいのはコードではなく、日本語見出しのid設計と、固定ヘッダーとのスクロール位置の折り合いのほうにあります。

今日やることを整理すると、こうなります。

  • toc.js と toc.css をアセットに置く
  • CONTENT_SELECTOR を検証ツールで確かめて書き換える
  • 本文に id="js-toc" の箱を置く(WordPressはフィルタで自動化してもよい)
  • scroll-margin-top を固定ヘッダーの高さに合わせる
  • rootMargin の下側の値を調整して、ハイライトの追従感を決める

この5つで動きます。1件目は前提の確認とセレクタ探しに時間がかかりますが、2件目以降はセレクタと色を変えるだけです。プラグインを1つ減らせるうえに、マークアップもデザインも自分の手の中に残ります。

テンプレートを触れる案件では、こういう小さな処理を自前で持っておくほど、後の改修が楽になります。