Astroブログに目次機能を追加して読者体験を改善した話 - スクロール連動とsticky配置の実装

公開: 2025年11月7日

はじめに

前回の記事「GA4・GTM導入から人気ランキング自動化まで1時間で設定した話」では、データを見える化してブログ運営のモチベーションを維持する仕組みを作りました。

今回はその続編として、読者体験を向上させるUI改善に取り組みました。具体的には、ブログ記事詳細ページに目次機能を追加し、長文記事でも快適に読めるようにしました。

目次機能の必要性

企業ブログを運営していて気づいたのは、長文記事でのナビゲーション課題です。

特に技術記事やガイド記事では、読者が知りたい情報だけを素早く見つけられることが重要です。目次があれば、記事の全体像を把握し、必要な箇所に直接アクセスできます。

また、SEOの観点からも、目次は検索エンジンが記事構造を理解するのに役立ちます。

技術的な実装方針

Astroサイトで目次を実装するには、以下の課題がありました:

  1. HTMLコンテンツから見出しを抽出する
  2. 見出しにIDを付与する
  3. 目次コンポーネントを作成する
  4. スクロール連動機能を実装する
  5. stickyで固定する

microCMSから取得したHTMLコンテンツは既にHTML形式なので、パースして見出しを抽出する必要があります。node-html-parserを使ってHTMLを解析し、見出し要素を取得しました。

実装の詳細

見出しの抽出とID付与

まず、src/lib/toc.tsで目次生成のユーティリティ関数を作成しました。

import { parse } from 'node-html-parser';

export interface TocItem {
  id: string;
  text: string;
  level: 2;
}

export function generateToc(htmlContent: string): {
  toc: TocItem[];
  htmlWithIds: string;
} {
  const root = parse(htmlContent);
  const toc: TocItem[] = [];

  // H2要素のみを検索
  const headings = root.querySelectorAll('h2');

  headings.forEach((heading) => {
    const text = heading.textContent.trim();
    if (!text) return;

    // 既にIDが設定されている場合はそれを使用、なければ生成
    let id = heading.getAttribute('id');
    if (!id) {
      id = generateSlug(text);
      // 重複チェック
      const existingIds = toc.map(item => item.id);
      let uniqueId = id;
      let counter = 1;
      while (existingIds.includes(uniqueId)) {
        uniqueId = `${id}-${counter}`;
        counter++;
      }
      id = uniqueId;
    }

    // 見出し要素にIDを付与
    heading.setAttribute('id', id);

    toc.push({
      id,
      text,
      level: 2,
    });
  });

  return {
    toc,
    htmlWithIds: root.toString(),
  };
}

H2のみを抽出する設計判断

最初はH2とH3の両方を抽出する予定でしたが、H2のみに絞りました。理由は以下の通りです:

スクロール連動機能

目次をクリックしたときのスムーススクロールだけでなく、ページをスクロールしたときに現在の見出しをハイライトする機能を実装しました。

最初はIntersection Observerを使おうとしましたが、複数の見出しが同時に検出される問題がありました。最終的には、スクロールイベントベースの実装に変更しました。

const updateActiveHeading = () => {
  const offset = 150; // ヘッダー分のオフセット
  const scrollPosition = window.scrollY + offset;

  // すべての見出しの位置を確認
  const headingPositions = headings
    .map(({ heading, id }) => {
      if (!heading || !id) return null;
      const rect = heading.getBoundingClientRect();
      return {
        id,
        top: rect.top + window.pageYOffset,
      };
    })
    .filter((item) => item !== null)
    .sort((a, b) => a.top - b.top);

  // 現在のスクロール位置より上にある見出しのうち、最も下のものを選択
  let currentHeading = null;
  for (let i = headingPositions.length - 1; i >= 0; i--) {
    if (headingPositions[i].top <= scrollPosition) {
      currentHeading = headingPositions[i];
      break;
    }
  }

  // アクティブな見出しが変わった場合のみ更新
  if (currentHeading && currentHeading.id !== activeHeadingId) {
    activeHeadingId = currentHeading.id;
    // ハイライト処理
  }
};

// requestAnimationFrameでスロットル
let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    window.requestAnimationFrame(() => {
      updateActiveHeading();
      ticking = false;
    });
    ticking = true;
  }
}, { passive: true });

requestAnimationFrameでスロットルすることで、パフォーマンスを最適化しています。

sticky配置の試行錯誤

ここが一番苦労した部分です。

最初のアプローチ:サイドバー内に目次を配置(2カラム構成)

<div class="flex flex-col lg:flex-row gap-12">
  <!-- サイドバー -->
  <div class="w-full lg:w-1/3">
    <BlogSidebar toc={toc} categories={categories} />
  </div>
  
  <!-- メインコンテンツ -->
  <div class="w-full lg:w-2/3">
    <!-- 記事コンテンツ -->
  </div>
</div>

BlogSidebarコンポーネント内で目次にlg:stickyを設定しましたが、うまく動作しませんでした

問題点:

解決策:目次を独立させて3カラムレイアウトに変更

<div class="flex flex-col gap-12 lg:flex-row lg:items-start">
  <!-- 目次(独立したカラム) -->
  {toc.length > 0 && (
    <div class="hidden lg:block w-64 order-1 lg:sticky lg:top-24 lg:self-start">
      <TableOfContents toc={toc} />
    </div>
  )}
  
  <!-- メインコンテンツ -->
  <div class="w-full lg:w-2/3 order-2">
    <!-- 記事コンテンツ -->
  </div>
  
  <!-- サイドバー(人気記事とカテゴリ) -->
  <div class="w-full lg:w-1/3 order-3">
    <BlogSidebar categories={categories} />
  </div>
</div>

目次をサイドバーコンテナから完全に分離し、独立したカラムとして配置しました。これにより、stickyが正しく動作するようになりました。

レイアウトの最適化

2カラムから3カラムへの変更

当初は2カラム(サイドバー + コンテンツ)の設計でしたが、stickyの制約により3カラム(目次 + コンテンツ + サイドバー)に変更しました。

変更の理由:

結果:

目次を左側に配置した理由

最終的に、目次を左側に配置しました。理由は以下の通りです:

UXの細部調整

「相談する」ボタンの追加

目次の下に「相談する」ボタンを追加しました。読者が記事を読んで興味を持ったタイミングで、自然に問い合わせに誘導できます。

<!-- 相談するボタン -->
<a
  href="/contact"
  class="mt-6 flex items-center justify-between w-full bg-white text-gray-900 font-semibold py-3 px-4 rounded-lg hover:bg-gray-100 transition-colors shadow-sm"
>
  <span>相談する</span>
  <Icon name="ArrowRight" class="h-5 w-5 text-gray-600" />
</a>

スクロールバーの非表示

目次が長い場合にスクロールバーが表示されますが、見た目をすっきりさせるため、スクロールバーを非表示にしました。

.toc-scroll-container {
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* IE and Edge */
}

.toc-scroll-container::-webkit-scrollbar {
  display: none; /* Chrome, Safari, Opera */
}

スクロールは可能ですが、スクロールバーは表示されません。

実装で学んだこと

stickyが動作する条件

stickyが正しく動作するためには、以下の条件が必要です:

  1. 親要素がoverflow: hiddenなどを持っていない
  2. 親要素の高さが十分である
  3. sticky要素が親要素の範囲内にある

今回のケースでは、親要素がflex flex-colで、その中にsticky要素を配置していたため、親要素の高さが制限されていました。目次を独立させたことで、この問題を解決できました。

Astroでのクライアントサイドスクリプト

Astroでは、<script>タグ内のコードはクライアントサイドで実行されます。ただし、DOMが完全に読み込まれる前に実行される可能性があるため、初期化処理を複数回試行する仕組みを実装しました。

let attempts = 0;
const maxAttempts = 10;
const tryInit = () => {
  attempts++;
  if (checkAndInit()) {
    console.log('目次スクロール連動機能を初期化しました');
  } else if (attempts < maxAttempts) {
    setTimeout(tryInit, 100);
  }
};

パフォーマンスの最適化

スクロールイベントは頻繁に発火するため、requestAnimationFrameでスロットルすることでパフォーマンスを最適化しました。また、{ passive: true }オプションを指定することで、スクロールのパフォーマンスも向上しています。

まとめ

目次機能の実装を通じて、以下のことを学びました:

  1. stickyの動作条件を理解する重要性

  2. 読者体験の小さな改善が継続につながる

  3. 実装の試行錯誤が学びになる

前回の記事では「データを見える化してモチベーションを維持する」仕組みを作り、今回は「読者体験を向上させるUI改善」に取り組みました。

次は、記事の読み込み速度の最適化や、関連記事の推薦機能の改善などに取り組みたいと考えています。

会社ブログ運営、引き続き頑張りましょう。同じ技術スタックで読者体験を改善したい方向けに、何かお役に立てていれば幸いです。