公開: 2025年11月7日
前回の記事「GA4・GTM導入から人気ランキング自動化まで1時間で設定した話」では、データを見える化してブログ運営のモチベーションを維持する仕組みを作りました。
今回はその続編として、読者体験を向上させるUI改善に取り組みました。具体的には、ブログ記事詳細ページに目次機能を追加し、長文記事でも快適に読めるようにしました。
企業ブログを運営していて気づいたのは、長文記事でのナビゲーション課題です。
特に技術記事やガイド記事では、読者が知りたい情報だけを素早く見つけられることが重要です。目次があれば、記事の全体像を把握し、必要な箇所に直接アクセスできます。
また、SEOの観点からも、目次は検索エンジンが記事構造を理解するのに役立ちます。
Astroサイトで目次を実装するには、以下の課題がありました:
microCMSから取得したHTMLコンテンツは既にHTML形式なので、パースして見出しを抽出する必要があります。node-html-parserを使ってHTMLを解析し、見出し要素を取得しました。
まず、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でスロットルすることで、パフォーマンスを最適化しています。
ここが一番苦労した部分です。
最初のアプローチ:サイドバー内に目次を配置(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を設定しましたが、うまく動作しませんでした。
問題点:
stickyは親要素の範囲内でしか動作しないflex flex-colの親要素内では、親要素の高さが制限される解決策:目次を独立させて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カラム(サイドバー + コンテンツ)の設計でしたが、stickyの制約により3カラム(目次 + コンテンツ + サイドバー)に変更しました。
変更の理由:
stickyは親要素の範囲内でしか動作しない結果:
最終的に、目次を左側に配置しました。理由は以下の通りです:
目次の下に「相談する」ボタンを追加しました。読者が記事を読んで興味を持ったタイミングで、自然に問い合わせに誘導できます。
<!-- 相談するボタン -->
<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が正しく動作するためには、以下の条件が必要です:
overflow: hiddenなどを持っていないsticky要素が親要素の範囲内にある今回のケースでは、親要素がflex flex-colで、その中にsticky要素を配置していたため、親要素の高さが制限されていました。目次を独立させたことで、この問題を解決できました。
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 }オプションを指定することで、スクロールのパフォーマンスも向上しています。
目次機能の実装を通じて、以下のことを学びました:
stickyの動作条件を理解する重要性
stickyの動作に大きく影響する読者体験の小さな改善が継続につながる
実装の試行錯誤が学びになる
前回の記事では「データを見える化してモチベーションを維持する」仕組みを作り、今回は「読者体験を向上させるUI改善」に取り組みました。
次は、記事の読み込み速度の最適化や、関連記事の推薦機能の改善などに取り組みたいと考えています。
会社ブログ運営、引き続き頑張りましょう。同じ技術スタックで読者体験を改善したい方向けに、何かお役に立てていれば幸いです。