今回は、シンプルなHTMLとJavaScriptだけで、Webページに目次(TOC: Table of Contents の略)を実装、見出しから目次を自動生成 できるJavaScriptライブラリ「inppend-toc.js」をご紹介します。
サンプル
なぜJavaScriptで目次を作るのか?
ユーザーにとってのメリット
- ✅ 長文記事でも迷わない - 全体構造が一目で分かる
- ✅ ワンクリックでジャンプ - 読みたい箇所へ瞬時に移動
- ✅ 現在地が分かる - アクティブハイライトで読んでいる場所を強調
開発者にとってのメリット
- ✅ WordPressに依存しない - CodePenやGitHub Pagesでも使用可能
- ✅ 一度作れば使い回し放題 - どんなサイトでも即座に導入
- ✅ カスタマイズが自由自在 - h2とh3でスタイルを分ける、レスポンシブ対応など
- ✅ 複数目次対応 - 同一ページで複数の目次セクションを管理可能
- ✅ SEO効果 - 構造化されたコンテンツは検索エンジンに好まれる
inppend-toc.js の特徴
主要機能
| 機能 | 説明 |
|---|---|
| アクティブハイライト | スクロール位置に応じて現在の見出しを強調表示 |
| スムーススクロール | anime.js 対応 + 独自のカスタム実装 |
| 階層構造対応 | H1 > H2 > H3 の入れ子構造を自動生成 (今のところ必要がないので使ってないですが、H2クリックで下階層が開くアコーディオン形式の対応を想定しています) |
| 除外機能 | .ignore-toc-scope で特定の見出しを目次から除外 |
| 複数目次対応 | 1ページに複数の独立した目次を配置可能 |
| パフォーマンス最適化 | DocumentFragment、requestAnimationFrame を活用 |
inppend-tocList.js - 静的な目次リスト

inppend-tocBtn.js - フローティングボタン型

両方併用
使い方:3ステップで完了
Step 1: HTML構造を作成
<!-- 目次対象範囲 -->
<div class="toc-scope" data-toc-id="js-tocBWGD">
<section>
<h1>メインタイトル</h1>
<p>コンテンツ...</p>
<h2>サブタイトル</h2>
<p>コンテンツ...</p>
<h3>小見出し</h3>
<p>コンテンツ...</p>
<h2 class="ignore-toc-scope">目次に表示しない見出し</h2>
<p>コンテンツ...</p>
</section>
</div>
<!-- 目次リスト挿入位置 -->
<nav class="toc-container">
<ins class="tocList" data-toc-id="js-tocBWGD"></ins>
</nav>
重要ポイント:
data-toc-idを同じ値にする(目次とスコープを紐付け).ignore-toc-scopeで特定の見出しを除外可能
Step 2: JavaScriptファイルを読み込み
パターンA:静的リスト型
<!-- inppend-tocList.js -->
<script src="https://gist.github.com/sarap422/425822a18cf9b68a93a3bba298720d5b.js"></script>
パターンB:フローティングボタン型
<!-- inppend-tocBtn.js -->
<script src="https://gist.github.com/sarap422/fe267c2a4e301b069dc294b438414b74.js"></script>
パターンC:両方併用
<!-- 両方読み込むことも可能 -->
<script src="https://gist.github.com/sarap422/425822a18cf9b68a93a3bba298720d5b.js"></script>
<script src="https://gist.github.com/sarap422/fe267c2a4e301b069dc294b438414b74.js"></script>
<!-- HTML -->
<ins class="tocList" data-toc-id="js-tocBWGD"></ins>
<ins class="tocBtn is-fixed" data-toc-id="js-tocBWGD"></ins>
<div class="toc-scope" data-toc-id="js-tocBWGD">
<!-- コンテンツ(目次対象範囲) -->
⌇
</div>
Step 3: 完成!
ページを開くと、自動的に目次が生成されます。
✅ 見出しタグから自動生成
✅ スクロールでアクティブハイライト
✅ クリックでスムーススクロール
✅ 階層構造を維持
カスタマイズ方法
1. CSS のカスタマイズ
H3を2列レイアウトにする
/* H3だけを横並び50%幅に */
.tocList .tocList-subList .tocList-item.level-h3 {
width: 50%;
}
アクティブハイライトの色を変更
/* inppend-tocList.js のアクティブ背景色 */
.tocList.is-fixed .tocList-item.is-active > .tocList-link::before {
background: var(--c-secondary-100, hsl(223, 24%, 93%));
}
/* inppend-tocBtn.js のアクティブ背景色 */
.tocBtn.is-fixed .tocBtn-item.is-active > .tocBtn-link::before {
background: var(--c-secondary-100, hsl(223, 24%, 93%));
}
パネル・ボタンの位置を変更(tocBtn)
/* 左下に配置 */
.tocBtn-panel {
bottom: 60px;
left: min(3.5rem, 4.2vw); /* 右→左に変更 */
right: auto;
transform-origin: 0% 100%; /* 100%→0%に変更 */
}
.tocBtn-switchGroup {
right: auto;
left: -2px; /* 右→左に変更 */
}
2. オフセットの調整
スクロールオフセット(スムーススクロール時)
// inppend-tocList.js の 238行目付近
const offset = 144; // ← ヘッダーの高さに合わせて調整
// inppend-tocBtn.js の 413行目付近
const offset = 144; // ← ヘッダーの高さに合わせて調整
調整のポイント:
- 固定ヘッダーがある場合は、その高さ分を設定
- ヘッダーが
height: 100pxならoffset: 100に設定
アクティブハイライトのオフセット
// inppend-tocList.js の 275行目付近
const tocListScrlPos = window.scrollY + 200; // ← 値を調整
// inppend-tocBtn.js の 458行目付近
const tocBtnScrlPos = window.scrollY + 200; // ← 値を調整
調整のポイント:
- 値を大きくする → より下にスクロールしてからハイライトされる
- 値を小さくする → より早くハイライトされる
- 推奨値:
100〜200の範囲
技術的解説:アクティブハイライトの実装
目次の中で最も実装が難しかったのが「スクロール位置に応じて現在の見出しをハイライトする」機能でした。
よくある失敗例
// ❌ 間違った実装
window.addEventListener('scroll', () => {
const items = document.querySelectorAll('.toc-item');
items.forEach(item => {
const target = document.getElementById(item.dataset.targetId);
// 画面内にあるかチェック
if (target.getBoundingClientRect().top >= 0 &&
target.getBoundingClientRect().top <= window.innerHeight) {
item.classList.add('is-active');
} else {
item.classList.remove('is-active');
}
});
});
問題点:
- ❌ 複数の見出しが同時にハイライトされる
- ❌ スクロールの度に全要素をチェック(パフォーマンス悪)
- ❌ 見出しが画面外に出るとハイライトが消える
正しい実装(inppend-toc.js の方式)
function fnTocListActiveHL(tocList, tocListChapters) {
let tocListTicking = false;
function fnTocListActiveItem() {
if (!tocListTicking) {
window.requestAnimationFrame(() => {
const tocListItems = tocList.querySelectorAll('.tocList-item');
const tocListScrlPos = window.scrollY + 200; // オフセット
let tocListCrntSect = null;
// 各目次アイテムを順番にチェック
tocListItems.forEach(item => {
const tocListTgtId = item.getAttribute('data-target-id');
const tocListTgtElem = document.getElementById(tocListTgtId);
if (tocListTgtElem) {
const tocListRect = tocListTgtElem.getBoundingClientRect();
const tocListAbsoTop = tocListRect.top + window.scrollY;
// ★ポイント:スクロール位置より上にある見出しの中で最も下のものを選択
if (tocListAbsoTop <= tocListScrlPos) {
tocListCrntSect = item;
}
}
});
// 全てのアクティブクラスを削除
tocListItems.forEach(item => item.classList.remove('is-active'));
// 現在のセクションにアクティブクラスを追加
if (tocListCrntSect) {
tocListCrntSect.classList.add('is-active');
}
tocListTicking = false;
});
tocListTicking = true;
}
}
// スクロールイベント
window.addEventListener('scroll', fnTocListActiveItem, { passive: true });
// 初回実行
fnTocListActiveItem();
}
実装のポイント
1. requestAnimationFrame によるスロットリング
let tocListTicking = false;
if (!tocListTicking) {
window.requestAnimationFrame(() => {
// 処理
tocListTicking = false;
});
tocListTicking = true;
}
効果:
- ✅ 毎フレーム(約16ms)に1回だけ実行
- ✅ 無駄な処理を削減してパフォーマンス向上
- ✅ スクロールが滑らかに
2. 「スクロール位置より上にある最後の要素」を選択
let tocListCrntSect = null;
tocListItems.forEach(item => {
const absoluteTop = rect.top + window.scrollY;
// スクロール位置より上にあれば更新し続ける
if (absoluteTop <= tocListScrlPos) {
tocListCrntSect = item; // ← 更新し続ける(break無し)
}
});
// ループ終了後、最後に残った要素がアクティブ
if (tocListCrntSect) {
tocListCrntSect.classList.add('is-active');
}
ロジック:
スクロール位置: 500px の場合
見出し1: 100px ← 上にある(更新)
見出し2: 300px ← 上にある(更新)
見出し3: 450px ← 上にある(更新)← これがアクティブ
見出し4: 700px ← 下にある(更新しない)
見出し5: 900px ← 下にある(更新しない)
結果:見出し3 がアクティブになる
なぜ break を使わないのか?
- ❌
breakを使うと最初に見つかった要素で停止 - ✅
break無しで最後まで更新し続けることで、「最も近い要素」を選択
3. passive イベントリスナー
window.addEventListener('scroll', fnTocListActiveItem, { passive: true });
効果:
- ✅ ブラウザに「preventDefault() を呼ばない」と伝える
- ✅ スクロールパフォーマンスが向上
- ✅ 特にモバイルで効果大
デバッグ方法
アクティブハイライトがうまく動かない場合、以下をチェック:
// デバッグ用コードを追加
tocListItems.forEach(item => {
const targetId = item.getAttribute('data-target-id');
const targetElem = document.getElementById(targetId);
if (targetElem) {
const absoluteTop = targetElem.getBoundingClientRect().top + window.scrollY;
console.log(`${targetId}: ${absoluteTop}px, スクロール位置: ${window.scrollY}px`);
}
});
確認ポイント:
- ✅
data-target-idが正しく設定されているか - ✅ 見出し要素に
idが存在するか - ✅ オフセット値が適切か
パフォーマンス最適化
1. DocumentFragment による DOM 操作の最小化
// ❌ 悪い例:逐次DOM挿入
items.forEach(item => {
const el = document.createElement('li');
el.textContent = item;
document.body.appendChild(el); // ← 毎回レンダリング
});
// ✅ 良い例:一括DOM挿入
const fragment = document.createDocumentFragment();
items.forEach(item => {
const el = document.createElement('li');
el.textContent = item;
fragment.appendChild(el); // ← メモリ上で処理
});
document.body.appendChild(fragment); // ← 一度だけレンダリング
効果:
- レンダリング回数を大幅削減
- 目次項目が100個あっても1回のレンダリングで完了
2. イベントリスナーの重複防止
// グローバルイベント:パネル外クリックで閉じる(1度だけ登録)
if (!window._tocBtnOutsideClickRegistered) {
document.addEventListener('click', (e) => {
const openedTocBtn = document.querySelector('.tocBtn.is-opened');
if (openedTocBtn && !openedTocBtn.contains(e.target)) {
openedTocBtn.classList.remove('is-opened');
}
});
window._tocBtnOutsideClickRegistered = true;
}
効果:
- 複数の目次があってもイベントリスナーは1つだけ
- メモリリークを防止
3. textContent の使用(XSS対策も兼ねる)
// ✅ 高速 & 安全
tocLink.textContent = heading.textContent;
// ❌ 遅い & XSSリスク
tocLink.innerHTML = heading.innerHTML;
実際のサンプルコード
サンプル1:基本的な静的リスト(.is-fixed)

<!-- 目次 -->
<aside>
<ins class="tocList is-fixed" data-toc-id="js-tocMain"></ins>
</aside>
<!-- コンテンツ -->
<main class="toc-scope" data-toc-id="js-tocMain">
<h1>はじめに</h1>
<p>コンテンツ...</p>
<h2>概要</h2>
<p>コンテンツ...</p>
<h3>詳細1</h3>
<p>コンテンツ...</p>
<h3>詳細2</h3>
<p>コンテンツ...</p>
<h2>まとめ</h2>
<p>コンテンツ...</p>
</main>
<script src="https://gist.github.com/sarap422/425822a18cf9b68a93a3bba298720d5b.js"></script>
サンプル2:フローティングボタン型

<!-- フローティングボタン -->
<ins class="tocBtn is-fixed" data-toc-id="js-tocArticle"></ins>
<!-- コンテンツ -->
<article class="toc-scope" data-toc-id="js-tocArticle">
<h2>セクション1</h2>
<p>長いコンテンツ...</p>
<h2>セクション2</h2>
<p>長いコンテンツ...</p>
<h3>サブセクション2-1</h3>
<p>長いコンテンツ...</p>
</article>
<script src="https://gist.github.com/sarap422/fe267c2a4e301b069dc294b438414b74.js"></script>
サンプル3:両方併用

<!-- 目次(.is-fixed) -->
<ins class="tocList is-fixed" data-toc-id="js-tocBlog"></ins>
<!-- フローティングボタン -->
<ins class="tocBtn is-fixed" data-toc-id="js-tocBlog"></ins>
<!-- コンテンツ -->
<article class="toc-scope" data-toc-id="js-tocBlog">
<h2>セクション1</h2>
<p>長いコンテンツ...</p>
<h2>セクション2</h2>
<p>長いコンテンツ...</p>
<h3>サブセクション2-1</h3>
<p>長いコンテンツ...</p>
</article>
<script src="https://gist.github.com/sarap422/425822a18cf9b68a93a3bba298720d5b.js"></script>
<script src="https://gist.github.com/sarap422/fe267c2a4e301b069dc294b438414b74.js"></script>
まとめ
inppend-toc.js は、以下の特徴を持つ目次生成ライブラリです:
実装済み機能- アクティブハイライト(スクロール連動)
- スムーススクロール(anime.js対応)
- 階層構造(H1 > H2 > H3)
- 除外機能(
.ignore-toc-scope) - data-toc-id="" を分けることでの、同一ページでの複数目次表示
- パフォーマンス最適化(DocumentFragment、requestAnimationFrame)
- イベントリスナー重複防止
リンク
- GitHub Gist(inppend-tocList.js): https://gist.github.com/sarap422/425822a18cf9b68a93a3bba298720d5b
- GitHub Gist(inppend-tocBtn.js): https://gist.github.com/sarap422/fe267c2a4e301b069dc294b438414b74