Next.jsのブログに、今読んでいる見出しが光る追従目次を実装する【IntersectionObserverでスクロールスパイ】
- この記事で分かること
- rehypeプラグインでh2見出しを集めて目次データを作り、PCではsticky+IntersectionObserverの追従目次、モバイルでは本文中の通常の目次を出し分ける実装
- 実際に使った環境
- Next.js 16.3.1(App Router)/ unified(remark・rehype)/ Tailwind CSS v4。このブログの記事ページで実際に動いている実装です
- 筆者の結論
- スクロールスパイ自体は十数行で書ける。手間がかかったのは周辺で、滑らかスクロールをCSSで全体にかけたらNext.jsのページ遷移が壊れた件と、sticky要素の重なり順だった
このブログの記事ページをPCで開くと、右側のサイドバーの一番上に目次が表示されます。この目次はスクロールしても画面に残り続け、今読んでいる見出しに色が付きます。こうした「現在位置を目次に反映する」仕組みは、スクロールスパイと呼ばれます。
この記事では、Markdownから目次のデータを作るところから、ブラウザのIntersectionObserverで現在位置を判定するところまでを、実際のコードで紹介します。途中で踏んだ2つの不具合も記録しておきます。
🛠 実装してみて分かったこと
今読んでいる見出しを光らせる部分は、IntersectionObserverを使えば十数行で書けました。時間がかかったのは、その周りです。目次クリックで滑らかにスクロールさせようとCSSを1行足したら、別の記事へ移動したときにページの途中で止まるようになった。サイドバーと目次を並べたら、目次の裏にサイドバーの文字が透けて見えた。どちらも、機能そのものとは離れた場所で起きた問題でした。
全体の構成
目次は、画面幅によって2種類を出し分けています。
| 画面幅 | コンポーネント | 置き場所 | 動き |
|---|---|---|---|
PC(lg以上) | TocSticky | サイドバー列の一番上 | スクロールに追従し、現在の見出しをハイライト |
| モバイル・タブレット | TableOfContents | 記事本文の上 | 通常の目次(追従しない) |
画面の狭い端末では、サイドバー列が本文の下に回り込みます。そこに追従する目次を置いても意味が無いため、本文の上に普通の目次を出しています。どちらも同じ目次データ(見出しの一覧)を使います。
目次のデータを作る: rehypeプラグインでh2を集める
このブログの記事はMarkdownで書き、unified(remark・rehype)でHTMLに変換しています。この変換の途中に、h2見出しを集める小さなプラグインを挟んでいます。
/**
* h2要素を歩いて {id, text} を集め、渡された配列に詰めていく。
* rehype-slugより後段に置くことで、既にid付与済みの見出しから拾う。
*/
function collectHeadings(target: Heading[]) {
return () => (tree: Root) => {
visit(tree, "element", (node: Element) => {
if (node.tagName === "h2" && typeof node.properties?.id === "string") {
target.push({ id: node.properties.id, text: hastToString(node) });
}
});
};
}変換の流れの中での位置が大事です。
const headings: Heading[] = [];
const processed = await unified()
.use(remarkParse)
.use(remarkGfm)
.use(remarkRehype, { allowDangerousHtml: true })
.use(rehypeRaw)
.use(rehypeSlug)
.use(collectHeadings(headings))
// ...(コードのハイライトなど)
.use(rehypeStringify, { allowDangerousHtml: true })
.process(content);
return { contentHtml: processed.toString(), headings };rehype-slugの後に置く: 見出しにid属性を付けるのはrehype-slugの仕事です。その後に置くことで、id付きの見出しから目次を作れます。目次のリンク先(#id)と本文の見出しのidが必ず一致しますrehype-rawの後に置く: このブログでは、記事内の装飾ボックスなどをMarkdownの中に生のHTMLで書いています。rehype-rawでそれを要素として展開してから処理することで、後段のプラグインが本文全体をたどれるようにしています
HTMLへの変換と同時に目次のデータも手に入るので、記事ページはこの2つを受け取って表示するだけです。
現在の見出しを判定する: IntersectionObserver
PC用の追従目次(TocSticky)は、ブラウザ上でスクロールに反応する必要があるため、クライアントコンポーネントにしています。
"use client";
export default function TocSticky({ headings }: { headings: Heading[] }) {
const [activeId, setActiveId] = useState<string | null>(null);
useEffect(() => {
if (headings.length === 0) return;
const targets = headings
.map((h) => document.getElementById(h.id))
.filter((el): el is HTMLElement => el !== null);
if (targets.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries.filter((entry) => entry.isIntersecting);
if (visible.length === 0) return;
// 複数の見出しが同時に範囲内にある場合は、一番上にあるものを採用する
const topMost = visible.reduce((a, b) =>
a.boundingClientRect.top < b.boundingClientRect.top ? a : b
);
setActiveId(topMost.target.id);
},
{
rootMargin: "-96px 0px -70% 0px",
threshold: 0,
}
);
targets.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, [headings]);
if (headings.length < 2) return null;
// ...目次の描画
}IntersectionObserverは、監視している要素が「判定範囲」に入ったり出たりしたときに、ブラウザがコールバックを呼んでくれる仕組みです。スクロールのたびに全見出しの位置を自分で計算する必要がありません。
判定範囲を画面の上のほうに絞る
判定範囲を決めているのがrootMargin: "-96px 0px -70% 0px"です。上・右・下・左の順に、画面(ビューポート)の範囲をどれだけ縮めるかを指定しています。
- 上を96px縮める: 画面上部には固定表示のヘッダーがあります。ヘッダーに隠れている見出しは、読者には見えていないので判定から外します
- 下を70%縮める: 画面の下のほうに見出しが顔を出しただけで「今読んでいる」と判定されると、実際にはまだ前の節を読んでいるのにハイライトが先に進んでしまいます。判定範囲を画面の上部30%ほどに絞ることで、見出しが画面の上のほうまで来たときに切り替わるようにしています
この結果、判定範囲は「ヘッダーのすぐ下の、細長い帯」になります。見出しがこの帯を通過したときに、その見出しがハイライトされます。
見出しが帯に無いときは、前の見出しのまま
コールバックの中で、判定範囲に入っている見出しが1つも無いときは、何もせずにreturnしています。activeIdは更新されず、直前の見出しがハイライトされたままになります。
長い節を読んでいる間は、次の見出しがまだ下のほうにあり、帯の中には見出しがありません。このときに「ハイライトなし」になると、目次のどこを読んでいるのか分からなくなります。前の見出しを光らせ続けることで、「今はこの節を読んでいる」という表示を保っています。
目次がPC以外で動かないようにする
TocStickyはhidden lg:blockというクラスで、PC幅でだけ表示しています。見出しが2つ未満の短い記事では、目次を出す意味が無いため何も描画しません。
不具合1: 別の記事に移動すると、ページの途中で止まる
目次のリンクをクリックしたときに、見出しまでスッと滑らかにスクロールしてほしいと思い、最初はCSSで次の1行を足しました。
html {
scroll-behavior: smooth;
}目次のクリックは期待どおり滑らかになりました。ところが、この1行を入れてから、関連記事などのリンクで別の記事に移動すると、新しいページの先頭まで戻らず、途中で止まるようになりました。
原因は、scroll-behavior: smoothがページ全体のスクロールに効いていたことです。Next.jsは、ページを移動したときにスクロール位置を先頭に戻します。この「先頭に戻す」スクロールまで滑らかなアニメーションになり、アニメーションの途中で新しいページの内容に切り替わって高さが変わるため、先頭にたどり着く前に止まっていました。
そこで、CSSの指定を削除し、目次のクリックのときだけJavaScriptで滑らかにスクロールするようにしました。
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
const target = document.getElementById(id);
if (!target) return;
e.preventDefault();
target.scrollIntoView({ behavior: "smooth", block: "start" });
history.pushState(null, "", `#${id}`);
};リンク本来の動作(#idへのジャンプ)はpreventDefault()で止め、scrollIntoViewで滑らかにスクロールします。そのままだとURLに#idが付かないため、history.pushStateで付けています。見出しへのリンクをコピーして共有したときに、その位置から開けるようにするためです。モバイル用のTableOfContentsも、同じ方式にしています。
見出しにジャンプしたときに、固定表示のヘッダーの下に見出しが隠れないよう、CSSで見出しに余白も指定しています。
.prose h2 {
/* sticky headerとTOCからのアンカージャンプで見出しが隠れないよう余白を確保 */
scroll-margin-top: 5.5rem;
}不具合2: 目次の裏に、サイドバーの文字が透ける
サイドバー列には、追従する目次の下に、カテゴリー一覧や新着記事などの通常のサイドバーが続きます。記事ページでは、この2つをまとめてサイドバー列に渡しています。
<SiteGrid
sidebar={
<>
<TocSticky headings={post.headings} />
<Sidebar />
</>
}
>最初は、サイドバー列全体をまとめて追従(sticky)させていました。しかし、これは意図した動きではありませんでした。追従させたいのは目次だけで、サイドバーの他の部分は普通にスクロールで流れていってほしかったのです。
そこで目次だけをstickyにしたところ、別の問題が出ました。スクロールすると、目次の下にあったサイドバーが上に流れてきて、画面上部に止まっている目次と重なります。このとき、目次の後ろを通り過ぎるサイドバーの文字が、目次の余白部分から透けて見えていました。
目次側にz-10(重なり順を前面にする指定)と、不透明な背景色を付けることで直しました。
<nav
aria-label="目次"
className="hidden lg:block sticky top-20 z-10 max-h-[calc(100vh-6rem)] overflow-y-auto rounded-xl border border-[var(--border-color)] bg-[var(--surface)] px-5 py-4 shadow-sm"
>これで、流れてきたサイドバーは目次の裏に隠れるように通り過ぎていきます。見出しが多い記事で目次が画面に収まらない場合に備え、max-h-[calc(100vh-6rem)]とoverflow-y-autoで、目次の中だけをスクロールできるようにしています。
ハイライトの見た目
現在の見出しは、左側の縦線と文字を、サイトのアクセントカラー(オリーブグリーン)にして示しています。あわせてaria-current="location"を付け、スクリーンリーダーにも「現在の位置」であることが伝わるようにしています。
<a
href={`#${h.id}`}
onClick={(e) => handleClick(e, h.id)}
aria-current={isActive ? "location" : undefined}
className={`block border-l-2 py-1 pl-3 -ml-px leading-snug transition-colors ${
isActive
? "border-[var(--accent-olive)] font-semibold text-[var(--accent-olive)]"
: "border-transparent opacity-70 hover:opacity-100 hover:border-[var(--border-color)]"
}`}
>
{h.text}
</a>よくある質問
Q. スクロールイベントで見出しの位置を計算する方法ではだめですか? A. 動きますが、スクロールのたびに全見出しの位置を計算することになります。IntersectionObserverなら、見出しが判定範囲に出入りしたときだけブラウザが通知してくれるので、処理が軽く、コードも短く済みます。
Q. 目次をクリックしたときの滑らかなスクロールは、CSSのscroll-behavior: smoothでよいのでは?
A. このブログでは最初にそうしましたが、html全体にかけるとNext.jsのページ遷移時のスクロール位置のリセットまで滑らかにアニメーションしてしまい、別の記事に移動した直後にページの途中で止まる不具合が出ました。目次のクリック時だけJavaScriptのscrollIntoViewで滑らかにスクロールする方式に変えています。
Q. 見出しが画面から外れている間は、どこがハイライトされますか? A. 直前にハイライトされていた見出しのままになります。長い節を読んでいる途中で、次の見出しがまだ判定範囲に入っていない間も、今読んでいる節の見出しが光り続ける動きです。
まとめ
追従目次とスクロールスパイは、Markdownの変換時に見出しを集め、IntersectionObserverで判定範囲を画面上部の細い帯に絞るだけで実現できました。
一方で、実装中に出た2つの不具合は、どちらも「ページ全体に効くもの」が原因でした。scroll-behavior: smoothはページ遷移のスクロールまで巻き込み、sticky要素は後ろを流れる要素との重なり順を考える必要がありました。影響範囲の広い指定は、必要な場所だけに絞る。目次という小さな機能で、改めてそれを実感しました。