JavaScript で見出しから目次を生成する
ナビゲーションのない長いページは読者をいら立たせます。目的もなくスクロールし、現在地を見失い、離脱してしまいます。動的な目次(Table of Contents)は、既存の見出し構造をクリック可能なアンカーリンクへと自動的に変換することで、この問題を解決します。ライブラリやフレームワークは不要です。
本記事では、モダンな vanilla JavaScript を使って目次を構築する方法、よくあるエッジケースへの対処、アクセシブルな目次生成、そしてスクロールに応じて現在のセクションをハイライトする任意機能の実装について解説します。
重要なポイント
- スコープを絞ったセレクターで見出しを取得し、ヘッダーやサイドバー、フッターなど無関係な見出しを拾わないようにする。
- 見出しテキストをスラグ化し、重複をカウンターで追跡することで、衝突のない URL フレンドリーな ID を生成する。
- 生成したリストを
<nav>要素で囲み、aria-labelを付与することでアクセシブルなナビゲーションランドマークを作る。 - スクロールリスナーの代わりに
IntersectionObserverを用いて、現在のセクションを効率的にハイライトする。 scroll-behavior: smoothという 1 行の CSS ルールでスムーズスクロールを有効化する。
DOM 見出しナビゲーションの仕組み
中心となる考え方はシンプルです。
- DOM からすべての見出し要素を取得する。
- 各見出しに対して安全な
idを生成する。 - それらの ID を指すアンカーリンクのリストを構築する。
- 指定されたコンテナにリストを挿入する。
生の HTML を正規表現でパースする必要も、jQuery も不要です。querySelectorAll と標準の DOM メソッドだけで完結します。
見出しの選択と準備
const headings = document.querySelectorAll('article h2, article h3, article h4');
セレクターはコンテンツ領域、つまり article、main、または特定のコンテナにスコープを絞り込みましょう。そうすることで、サイトヘッダーやサイドバーの見出しを誤って取得することを防げます。
安全なアンカー ID の生成
見出しテキストには空白や特殊文字、句読点が含まれることが多く、URL フラグメントが読みにくくなったり、セレクターで扱いづらくなったりします。使用前に各見出しの id を正規化しましょう。
function slugify(text) {
return text
.toLowerCase()
.trim()
.replace(/\s+/g, '-')
.replace(/[^\w-]/g, '') || 'section';
}
重複は、使用済みスラグを追跡してカウンターを付与することで処理します。
const usedIds = new Map();
function uniqueSlug(text) {
const base = slugify(text);
const count = usedIds.get(base) ?? 0;
usedIds.set(base, count + 1);
return count === 0 ? base : `${base}-${count}`;
}
空、または空白のみの見出しは、シンプルなガードでスキップします: if (!heading.textContent.trim()) return;
Discover how at OpenReplay.com.
JavaScript で目次を構築する
ID の割り当てが済んだら、目次リストを構築してページに挿入します。
function buildTOC(containerSelector, headingSelector) {
const container = document.querySelector(containerSelector);
const headings = document.querySelectorAll(headingSelector);
if (!container || !headings.length) return;
const nav = document.createElement('nav');
nav.setAttribute('aria-label', 'Table of contents');
const ol = document.createElement('ol');
headings.forEach(heading => {
const text = heading.textContent.trim();
if (!text) return;
const id = heading.id || uniqueSlug(text);
heading.id = id;
const li = document.createElement('li');
const a = document.createElement('a');
a.href = `#${id}`;
a.textContent = text;
li.appendChild(a);
ol.appendChild(li);
});
nav.appendChild(ol);
container.appendChild(nav);
}
document.addEventListener('DOMContentLoaded', () => {
buildTOC('#toc', 'article h2, article h3');
});
DOMContentLoaded を使うことで、スクリプト実行時に見出しが確実に存在することが保証されます。
アクセシブルな目次生成
リストを aria-label="Table of contents" 付きの <nav> 要素で囲むことで、名前付きのナビゲーションランドマークが作成されます。スクリーンリーダーはこれらのランドマークを直接提示するため、ユーザーはページを Tab キーで辿らずに目次へジャンプできます。
コンテンツ内では論理的な見出し階層を維持し、h2 から h4 へ飛ばさないようにしましょう。目次はドキュメント構造を反映するため、見出しレベルにギャップがあるとすべてのユーザーにとって分かりにくいナビゲーションになります。
IntersectionObserver でアクティブなセクションをハイライトする
現在表示中のセクションをハイライトするには、スクロールイベントリスナーではなく IntersectionObserver API を使用します。
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
const id = entry.target.getAttribute('id');
const link = document.querySelector(`nav a[href="#${id}"]`);
if (link) link.classList.toggle('active', entry.isIntersecting);
});
}, { rootMargin: '0px 0px -80% 0px' });
document.querySelectorAll('article h2, article h3').forEach(h => observer.observe(h));
rootMargin で検出ゾーンを縮小し、見出しがビューポート上部付近に来たときにのみアクティブとしてマークされるようにしています。このアプローチはスロットリングされたスクロールハンドラよりも高パフォーマンスで、ほとんどの静的ページではクリーンアップ処理も不要です。IntersectionObserver はすべてのモダンブラウザでサポートされています。
スムーズスクロール
JavaScript を一切使わず、ページ全体にスムーズスクロールを有効化するには、次の 1 行の CSS ルールを追加します。
html {
scroll-behavior: smooth;
}
scroll-behavior プロパティはすべてのモダンエバーグリーンブラウザでサポートされています。
まとめ
動作する JavaScript 目次に必要なのは 4 つの要素です。スコープを絞った見出しの選択、衝突しない ID 生成、アクセシブルな目次生成のためのセマンティックな <nav> ラッパー、そして DOMContentLoaded のタイミングです。IntersectionObserver の拡張は任意ですが、最小限のコードで意味のある UX を加えてくれます。
このパターンは、あらゆる静的サイト、ドキュメントページ、ブログで機能します。ビルドステップも依存関係もフレームワークも必要ありません。
FAQ
どちらでも機能しますが、クライアント側生成の方がシンプルで、ロジックが 1 箇所にまとまります。SEO が重要なページや JavaScript が無効化される可能性がある場合は、アンカーリンクと見出し ID が初期 HTML に含まれるサーバーサイドレンダリングが望ましいでしょう。ほとんどのブログやドキュメントサイトでは、DOMContentLoaded を用いたクライアント側生成で十分です。
新しい ID を生成する前に、既存の id をチェックしてください。見出しに手動で割り当てられた id がある場合は、それを再利用することで外部リンクやブックマークが機能し続けます。heading.id があれば既存の値を使用し、なければ uniqueSlug を呼ぶといった条件分岐を追加しましょう。これにより著者の意図が保たれ、特定セクションへのインバウンドリンクが壊れることを防げます。
スクロールイベントは 1 秒間に何十回も発火し、その都度レイアウト計算を強制するため、特にモバイルでパフォーマンスを損ないます。IntersectionObserver は可視性の変化を非同期に監視し、実際に変化があったときにのみ発火します。また、スロットリングやデバウンスのロジックを自分で書く必要なく、しきい値やマージンを宣言的に設定できる点でも扱いやすいです。
反復処理中に現在の見出しレベルを追跡します。次の見出しがより深いレベルの場合、直前の li の中にネストされた ol を作成します。より浅いレベルなら、親チェーンを遡ります。見出しレベルでインデックス付けされたリスト要素のスタックを保持することで、ロジックがクリーンに保たれ、各レベルをハードコーディングすることなく任意のネスト深度をサポートできます。
Understand every bug
Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.