今回は、シンプルな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');
        }
      });
    });
    

    問題点:

    1. ❌ 複数の見出しが同時にハイライトされる
    2. ❌ スクロールの度に全要素をチェック(パフォーマンス悪)
    3. ❌ 見出しが画面外に出るとハイライトが消える

    正しい実装(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`);
      }
    });
    

    確認ポイント:

    1. data-target-id が正しく設定されているか
    2. ✅ 見出し要素に id が存在するか
    3. ✅ オフセット値が適切か

    パフォーマンス最適化

    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)
    • イベントリスナー重複防止

    リンク

    参考記事