テクノロジー

【目次サイドバーで改善するユーザー体験】設計思想とAstroでの実装詳細について解説

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

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

前回せっかく作ったランキング導線と、既存のカテゴリ一覧導線も、まとめて個別記事内に表示をし、ブログの回遊性の上昇も同時に狙ってみました。

まず、何を作るか

今回のテーマは、「長文記事でも迷わず・疲れず・必要な情報にたどり着けるUIをつくる」ことです。
まずは元のUIを振り返りましょう。元のUIはこんな感じで、めっちゃシンプル、noteみたいな感じです。

ただ、元のUIに対して感じる課題が2つありました。

  1. 読んでいる最中の課題
     どこまで読んだのか分かりづらく、目的の情報にすぐたどり着けないのと、いつ読み終わるのかわからなくてウっとなる。
  2. 読む前・読んだあとの課題
     関心のある関連記事が(仮に)あっても、最後まで読まれない限り、関連記事セクションが末尾にしかないので気づかれない。

1と2については、課題の質が違うので、それぞれ対応しようと判断しました。

レイアウト構成の概要

今回実装後のページ構成は次のように整理されています。

位置

内容

役割

タイトルセクション左側

人気記事・カテゴリ(+関連記事ティザー)

読む前に関心を広げる

タイトルセクション右側

タイトル・日付・アイキャッチ+目次

読む前に構成を把握する

本文左

スクロール連動型の目次

読んでいる最中の現在地を示す

本文下

関連記事ブロック

読み終えた後の回遊導線

UI設計の意図は読前の把握と読中の迷わなさを両立すること

今回のレイアウトでは、ページ上部のタイトルセクションと
本文エリアの左サイドバー
の両方に目次を配置しています。

タイトルセクション内の目次

タイトル直下に目次を置くことで、記事を開いた瞬間に
「どんな構成で、どんな話が書かれているのか」を俯瞰できるようにしました。
これにより、読者は読む前から全体像を把握でき、「どこから読もうか」をすぐ判断できます。

本文エリアの目次(スクロール連動型)

本文に入ると、左側のサイドバーにスクロール連動型の目次が表示されます。
タイトルセクションをスクロールして本文に入ると、右の本文に対して左側で常に目次が追従する構成です。

理由は、読中の集中を妨げずに、読前のユーザーの関心への接続をつくるためです。

BtoBの長文記事は、最後まで読まれないケースも多く、「実は相性のいい記事があるのに気づかれず終わる」という機会損失が起こりがちです。

そのため、関連記事は記事冒頭でファーストビュー内に一度だけ表示し、「このテーマとつながる話が他にもある」と軽く気づいてもらう。
その後はスクロールに合わせて消え、読了後には従来どおり下部で再掲します。

「本文中部分にはそれ以外の記事の導線を表示しない」UIは、noteも似た設計思想だと思います。読んでる最中はコンテンツのみに集中しやすいように、諸々の関連記事やおすすめは末尾まで現れません。

noteでは関連記事に関しても読了まで出てきませんが、本ブログの場合は読了されない限り回遊や他記事との出会いが起こり得ない設計ももったいなーということで、間をとっている、という感じです。

実装の概要

1. 見出しの抽出と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[] = [];

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

    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;
    }

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

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

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

最初はH2とH3の両方を対象にしていましたが、H2のみに絞りました
長文記事で目次が長くなりすぎず、モバイルでも読みやすいバランスに収まります。

2. 追従型で目次が配置される実装

stickyが正しく動作するよう、目次をサイドバーから独立したカラムとして配置しました。

<section class="py-12">
  <div class="flex flex-col gap-12 lg:flex-row lg:items-start">
    <!-- 左:sticky TOC -->
    {toc.length > 0 && (
      <aside class="hidden lg:block w-80 order-1 lg:sticky lg:top-24 lg:self-start">
        <TableOfContents toc={toc} />
      </aside>
    )}

    <!-- 右:本文 -->
    <main class="w-full lg:flex-1 order-2">
      <article class="prose max-w-none content" set:html={htmlWithIds} />
    </main>
  </div>
</section>

stickyは親要素の高さやoverflow指定の影響を受けるため、
カラムを独立させることで安定して追従するようになりました。


3. スクロール連動(ハイライト処理)

const updateActiveHeading = () => {
  const offset = 150;
  const scrollY = window.scrollY + offset;
  const positions = headings
    .map(({ heading, id }) => {
      if (!heading || !id) return null;
      return { id, top: heading.getBoundingClientRect().top + window.pageYOffset };
    })
    .filter(Boolean)
    .sort((a, b) => a.top - b.top);

  let current = null;
  for (let i = positions.length - 1; i >= 0; i--) {
    if (positions[i].top <= scrollY) {
      current = positions[i];
      break;
    }
  }

  if (current && current.id !== activeId) {
    activeId = current.id;
    // ハイライト処理
  }
};

let ticking = false;
window.addEventListener('scroll', () => {
  if (!ticking) {
    window.requestAnimationFrame(() => {
      updateActiveHeading();
      ticking = false;
    });
    ticking = true;
  }
}, { passive: true });

requestAnimationFrame{ passive: true }で、
スクロール性能を保ちながらスムーズに更新できるようにしています。

UXの細部調整

関連記事を初手に持ってくる

人気記事・カテゴリと並んで、関連記事のリンクもファーストビューに軽く表示
読了前でも「関連する記事がある」ことに気づけるようにしました。
ただし、追従はせず、スクロールで自然に消える設計にしています。

読了後には従来通り記事下にも再掲し、前・中・後の3段階で回遊導線としました。

「相談する」ボタンの配置

目次の下に「相談する」ボタンを設置。
記事を読みながら「もう少し話を聞いてみたい」と思った瞬間に、
無理なく行動できる導線になっています。

実装で学んだこと

  1. stickyは構造に左右される
     → 親要素の高さやoverflow指定で効かなくなる。独立カラム化が安定。
  2. スクロールイベントは軽く保つ
     → requestAnimationFramepassiveで体感レスポンスを維持。
  3. 目次の位置がUXを変える
     → タイトル下と左サイドバー両方に置くことで、読前〜読中の導線がつながる。

まとめ

前回は「データを見える化してモチベーションを維持する」仕組みを整え、今回は「読者体験を改善するUI」を実装しました。

せっかく設計するなら、きちんとユーザービリティや運営側の意図の良い落とし所を探った上で意思のある実装をしようと思ったので、このような形でブログ記事にまとめてみました。
記事が増えてきたらそのうち関連記事のレコメンド機能の改善とか、より問い合わせ導線を自然にする、とか色々やりたいですね。

同じ技術スタックでブログを運用している方の参考になれば幸いです。

お問い合わせ

Kumonoでは、スタートアップの支援に関するご相談を承っております。
お気軽にお問い合わせください。

無料相談を予約する