Reactで、読んでいる見出しを目次で強調する方法
React で、いま読んでいる節の見出しを目次で強調するには、「固定ヘッダーの下端より上にある見出しのうち、いちばん下のもの」を強調する項目として選びます。位置を計算するきっかけには IntersectionObserver を使い、見出しがその線をまたいだときだけ計算し直します。画面の上のほうの帯に見出しが入ったら強調する書き方と比べたところ、そちらの書き方では、上へ戻ったときに強調が読んでいる節とずれ、ページを途中から開いたときには何も強調されませんでした。
検証環境は Ubuntu 24.04、Node.js 22.22.0、react / react-dom 19.3.1、@types/react 19.3.0、TypeScript 7.0.2、esbuild 0.28.2 です。ブラウザはヘッドレスの Chromium 153 で、Playwright 1.63.0 から操作しました。検証用のページは、高さ 56px の固定ヘッダーと、6つの節(長い節と短い節を混ぜたもの)を並べた記事で作っています。Firefox と Safari、実機のスマートフォンでは試していません。
次の GIF は、この記事のフックで、ページを 450px ずつ下へ 7 回、続けて上へ 4 回スクロールしたときの様子です。下へ進むと強調が「インストール」から「オプション」まで移り、上へ戻ると「初期設定」まで戻ります。強調が別の項目に移った瞬間だけ、その項目をピンクにしています。

見出しの位置から、強調する項目を決めるフック
フックは、見出しの id の配列を受け取り、強調する見出しの id を返します。offset は固定ヘッダーの高さで、画面の上端からこの高さの位置を「読んでいる位置の線」とみなします。endId は、記事の終わりに置いた要素の id です(使い方は後の節で説明します)。
import { useEffect, useState } from 'react';
type Options = {
offset?: number; // 固定ヘッダーの高さ(px)
endId?: string; // 記事の終わりに置いた要素の id
};
export function useActiveHeading(ids: readonly string[], { offset = 0, endId }: Options = {}) {
const [activeId, setActiveId] = useState<string | null>(null);
useEffect(() => {
const headings = ids
.map((id) => document.getElementById(id))
.filter((el): el is HTMLElement => el !== null);
if (headings.length === 0) return;
const end = endId ? document.getElementById(endId) : null;
// ヘッダーの下の線より上にある見出しのうち、いちばん下のものを選ぶ
const update = () => {
let current = headings[0].id;
for (const h of headings) {
if (h.getBoundingClientRect().top <= offset + 1) current = h.id;
else break;
}
// 記事の終わりまで見えたら、最後の見出しにする
if (end && end.getBoundingClientRect().top <= window.innerHeight) {
current = headings[headings.length - 1].id;
}
setActiveId(current);
};
// 見出しがヘッダーの下の線をまたいだときだけ、計算し直す
const observer = new IntersectionObserver(update, {
rootMargin: `-${offset}px 0px 0px 0px`,
threshold: [0, 1],
});
headings.forEach((h) => observer.observe(h));
if (end) observer.observe(end);
return () => observer.disconnect();
}, [ids, offset, endId]);
return activeId;
}
強調する項目は、IntersectionObserver のコールバックに渡される交差の情報からではなく、毎回すべての見出しの位置を getBoundingClientRect() で調べて決めます。見出しは記事の上から順に並んでいるので、線より上にある最後の見出しが、いま読んでいる節の見出しです。こうしておくと、見出しが画面に1つも見えていない長い節の途中でも、強調する項目が決まります。
IntersectionObserver は、計算し直すきっかけを作るためだけに使っています。MDN の IntersectionObserver() コンストラクターの説明にあるとおり、rootMargin は交差を判定する範囲を広げたり狭めたりする指定です。上を -56px のようにヘッダーの高さだけ削ると、判定する範囲の上端が、ちょうどヘッダーの下端になります。
threshold を [0, 1] にしているのは、見出しの上端が線を越えた時点で呼ばれるようにするためです。threshold は、要素のうち判定する範囲に入っている割合で、0 は1ピクセルでも入っていること、1 は全体が入っていることを表します。0 だけだと、見出しが少しでも範囲に残っている間は呼ばれません。検証環境で、高さ 64px の見出しを1つだけ監視して比べました。次の出力の回数は累計で、どちらも最初の1回は、見出しが判定する範囲に入ったとき(上端 70px)のものです。0 だけでは、そのあと見出しの上端が線より 56px 上(画面の上端から 0px)に来ても、回数が増えませんでした。[0, 1] では、上端が線を越えた直後(画面の上端から 50px)にコールバックが2回目に呼ばれました。
threshold [0]: 見出しの上端 70px → 呼ばれた回数 1 / 見出しの上端 50px → 呼ばれた回数 1 / 見出しの上端 30px → 呼ばれた回数 1 / 見出しの上端 0px → 呼ばれた回数 1
threshold [0,1]: 見出しの上端 70px → 呼ばれた回数 1 / 見出しの上端 50px → 呼ばれた回数 2 / 見出しの上端 30px → 呼ばれた回数 2 / 見出しの上端 0px → 呼ばれた回数 2
offset + 1 と 1px の余裕を持たせているのは、目次のリンクで見出しへ移動したときに、見出しの上端がちょうど 56px にならなかったためです(後の節で説明します)。
setActiveId には、強調が変わっていなくても毎回同じ id を渡しています。React の useState のリファレンスには、新しい値が今の値と Object.is で同じなら、再描画を省くと書かれています。そのため、強調が変わらないときは再描画が起きません。
目次の側では、いまの項目のリンクに aria-current を付けて強調する
目次のコンポーネントは、フックが返した id と同じ項目のリンクに aria-current="location" を付けます。MDN の aria-current の説明では、location は、ある環境や文脈の中での現在の位置を表す値で、フローチャートの中でいま強調されている図形が例に挙げられています。見た目の強調も、クラスではなくこの属性を CSS のセレクターに使って付けると、見た目とスクリーンリーダーに伝わる情報がずれません。
import { useActiveHeading } from './useActiveHeading';
type Section = { id: string; title: string };
export function TableOfContents({ sections, ids }: { sections: readonly Section[]; ids: readonly string[] }) {
const activeId = useActiveHeading(ids, { offset: 56, endId: 'article-end' });
return (
<nav className="toc" aria-label="目次">
<ol>
{sections.map((s) => (
<li key={s.id}>
<a href={`#${s.id}`} aria-current={activeId === s.id ? 'location' : undefined}>
{s.title}
</a>
</li>
))}
</ol>
</nav>
);
}
.toc a[aria-current="location"] {
color: #067a98;
font-weight: bold;
border-left-color: #00c8e8;
background: #eefafd;
}
フックに渡す ids の配列は、コンポーネントの外で作っておきます。描画のたびに map で新しい配列を作ると、依存配列の値が毎回変わり、IntersectionObserver の作り直しが描画のたびに起きます。React の useEffect のリファレンスでも、コンポーネントの中で作ったオブジェクトを依存配列に入れると、エフェクトが必要以上に実行し直されることがあると注意されています。
// 配列はコンポーネントの外で作り、描画のたびに作り直さない
const sections = [
{ id: 'install', title: 'インストール' },
{ id: 'setup', title: '初期設定' },
{ id: 'usage', title: '基本の使い方' },
] as const;
const ids = sections.map((s) => s.id);
帯に入った見出しを選ぶ書き方は、上へ戻るとずれる
比べる相手として、帯に入った見出しを選ぶ書き方も試しました。rootMargin の下を -70% にして、ヘッダーの下端から、画面の高さの 30% の位置までを帯にして、その帯に入った見出しを強調する書き方です。
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) setActiveId(entry.target.id);
}
},
{ rootMargin: `-${offset}px 0px -70% 0px` },
);
検証環境(幅 800px、高さ 600px)で、ページを 100px ずつ下へ、続けて上へスクロールし、そのたびに目次の強調と「ヘッダーのすぐ下にある節」を比べました。48 か所のうち、帯に入った見出しを選ぶ書き方では 25 か所がずれ、この記事のフックでは、記事の終わりの1か所(後の節で説明します)を除いて一致しました。出力の mode=naive が帯に入った見出しを選ぶ書き方、mode=io がこの記事のフックです。出力からは、必要な行だけを抜き出しています。
--- mode=naive(スクロールできる量 2270px、確かめた位置 48 か所、食い違い 25 か所)
上へ scrollY 2170〜1670: 読んでいる節=options 強調=faq
上へ scrollY 1570〜1370: 読んでいる節=usage 強調=options
上へ scrollY 1170〜370: 読んでいる節=setup 強調=usage
--- mode=io(スクロールできる量 2270px、確かめた位置 48 か所、食い違い 1 か所)
下へ scrollY 2270〜2270: 読んでいる節=options 強調=links(一番下)
ずれたのは、ほとんどが上へ戻るときです。帯に入った見出しを選ぶ書き方では、強調が変わるのは見出しが帯に入ったときだけです。上へ戻って前の節の本文を読んでいても、前の節の見出しが上から帯に入ってくるまで、後ろの節の見出しが強調されたままになります。長い節ほど、ずれている時間が長くなります。

上の画像は、一番下までスクロールしてから、「初期設定」の節の終わりまで上へ戻ったときの目次です。本文はどちらも「初期設定」の節の終わりですが、帯に入った見出しを選ぶ書き方(上)では、「基本の使い方」が強調されたままでした。
ページを途中から開いたときも違いが出ました。「初期設定」の節の8段落目を指す URL(#setup-p8)で開くと、帯に入った見出しを選ぶ書き方では、どの項目も強調されませんでした。帯の中に見出しが1つもないためです。この記事のフックでは「初期設定」が強調されました。
mode=naive: scrollY 920 強調=なし
mode=io: scrollY 920 強調=#setup
固定ヘッダーの下に見出しを止める
固定ヘッダーがあるページでは、見出しに scroll-margin-top を付けておきます。目次のリンクで見出しへ移動したとき、見出しがヘッダーの下に止まります。CSS Scroll Snap の仕様には、URL の # の先にある要素へ移動するときも、scroll-margin を含めた範囲を画面に入れると書かれています。
h2 {
scroll-margin-top: 56px;
}
検証環境で目次の「オプション」をクリックしたところ、scroll-margin-top を付けた場合は、見出しの上端が 56.3px に止まりました。付けない場合は 0.3px に止まり、見出しがヘッダーに隠れました。強調はどちらも「オプション」でした。
mode=io: 見出しの上端 56.3px(見えている) 強調=#options URL の # 以降=#options
mode=io&nomargin=1: 見出しの上端 0.3px(ヘッダーに隠れている) 強調=#options URL の # 以降=#options
見出しの上端は、ちょうど 56px ではなく 56.3px でした。56.3 は 56 より大きいので、判定が top <= offset だと、クリックした見出しは線より下にあるとみなされます。フックの判定に 1px の余裕を持たせているのはこのためです。
最後の短い節を強調する
記事の最後の節が短いと、一番下までスクロールしても、最後の見出しがヘッダーの下の線まで上がってきません。線より上の最後の見出しを選ぶだけでは、最後の節を読んでいても、その前の長い節が強調されたままになります。
この記事のフックでは、記事の終わりに置いた要素(endId)が画面に入ったら、最後の見出しを強調します。
<article>
{/* 節を並べる */}
<div id="article-end" />
</article>
ただし、この決め方では、最後から2番目の節も短いと、その節が強調されないまま最後の節に移ります。検証用のページでは「よくある質問」(3段落)と「関連リンク」(1段落)が短く、下へスクロールしたときの強調は「オプション」から「関連リンク」へ飛びました。
下へスクロールしたときの強調の移り変わり: install → setup → usage → options → links
すべての項目を順に強調したいときは、最後の節の下に余白を作り、最後の見出しも線まで上がってこられるようにします。検証では、最後の節の高さを画面の高さからヘッダーの高さを引いた分以上にし、endId を渡さずに試しました。強調は6つの節を順に移り、一番下でも読んでいる節と一致しました。
section:last-of-type {
min-height: calc(100vh - 56px);
}
--- mode=io&pad=1(スクロールできる量 2676px、確かめた位置 56 か所、食い違い 0 か所)
下へスクロールしたときの強調の移り変わり: install → setup → usage → options → faq → links
出力の mode=io&pad=1 が、この余白を付けて試したものです。記事の下に大きな余白ができるので、余白のない見た目と、すべての項目を順に強調することの、どちらを優先するかで選びます。
scroll イベントで計算する書き方との違い
同じ計算を、scroll イベントのたびに行う書き方でも試しました。100px ずつスクロールして比べた結果は、この記事のフックと同じで、ずれたのは記事の終わりの1か所だけでした。違いは、位置を計算する回数です。
scrollTo に behavior: 'smooth' を渡して、一番上から一番下まで滑らかにスクロールしたときの回数を数えました。scroll イベントは 47 回届き、scroll イベントで計算する書き方では 47 回計算しました。この記事のフックでは、見出しが線をまたいだときなどに呼ばれる 15 回だけでした。
mode=io: scrollY 2270 scroll イベント 47 回 位置の計算(コールバック)15 回
mode=scroll: scrollY 2270 scroll イベント 47 回 位置の計算(コールバック)47 回
mode=scroll が scroll イベントで計算する書き方です。MDN の Intersection Observer API の説明にあるとおり、イベントのたびに getBoundingClientRect() で位置を調べる処理は、メインスレッドで動きます。見出しが多いページや、ほかにもスクロールに合わせた処理があるページでは、計算する回数を抑えておくと、その分だけメインスレッドの仕事が減ります。計算にかかった時間そのものは測っていないので、この回数の差が操作の重さとしてどれだけ表れるかは確かめていません。
強調する項目は「線より上にある最後の見出し」として毎回位置から決め、IntersectionObserver は計算し直すきっかけにだけ使います。そのうえで、ヘッダーの高さを rootMargin と scroll-margin-top の両方に入れ、最後の短い節の扱いを決めておけば、上へ戻ったときも途中から開いたときも、強調が読んでいる節からずれません。





