1. はじめに
React + TypeScript でポートフォリオを制作している中で、スクロール位置に応じて目次の現在地を切り替える処理を実装しました。
今回は react-intersection-observer の useInView を使って実装したところ、隣のセクションが少し見えただけで現在地が切り替わったり、スクロールして戻ったときに現在地が正しく更新されなかったりする問題が発生しました。
そこで、threshold や rootMargin の設定を変更して挙動を比較し、最終的にセクションの表示状態を配列で管理する方法に改善しました。
この記事では、実際に試した3つのパターンを比較しながら、問題が発生した原因と改善方法を整理します。
2. 結論
今回の改善で重要だったのは、セクションが監視範囲に入ったかどうかだけでなく、監視範囲から外れたかどうかも管理することでした。
試した内容は次の3パターンです。
-
A:
threshold: 0.1、rootMargin: '0px'で監視する。 -
B:
threshold: 0、rootMargin: '-40% 0px'に変更して、切り替わるタイミングを調整する。 -
C:
trueとfalseの両方の通知を親コンポーネントに渡し、監視範囲内にあるセクションを配列で管理する。
A・Bでは、スクロールして戻った際に現在地が正しく更新されない問題が残りました。Cでは、各セクションの表示状態を配列で管理することで、この問題を改善できました。
3. デモページ・ソースコード
実際の挙動を確認できるように、3つのパターンを切り替えられるデモを用意しました。
- デモページ: https://hisakawaserai-svg.github.io/react-useinview-toc-demo/
- ソースコード: https://github.com/hisakawaserai-svg/react-useinview-toc-demo
デモページでは A・B・C を切り替えながら、現在地を確認できます。
C では配列を確認できます。
開発時のAI活用
今回の実装・検証では、複数のAIツールを活用しました。
-
Google AIモード:
react-intersection-observerのuseInViewの使い方を調べる際に活用。 - Claude Code: Cの実装方法の提案や、検証用デモページの制作に活用。
- Claude Design: デモページのレイアウト・デザインの制作に活用。
- ChatGPT: 記事の構成や文章の整理に活用。
AIから得た情報や提案をもとに実装を進め、A・B・Cの挙動を実際に比較しながら、今回の問題と改善方法を整理しました。
なお、記事で紹介している挙動の比較や改善内容は、実際にデモを動かして検証した結果をもとにまとめています。
検証環境
動作確認時に使用した主なパッケージのバージョンは以下のとおりです。
| パッケージ | バージョン |
|---|---|
| React | 19.3.0 |
| React DOM | 19.3.0 |
| react-intersection-observer | 11.0.1 |
| TypeScript | 6.0.3 |
| Vite | 8.3.4 |
※ バージョンは手元の node_modules で確認したものです。
4. 発生した問題
目次の現在地は、スクロールに応じて history、apps、about のように切り替わる想定でした。
しかし、実際には次の問題が発生しました。
- 隣のセクションが少し見えただけで、現在地が切り替わる。
- 複数のセクションが監視範囲内にある状態でスクロールして戻ると、現在地が正しく更新されない。
まずは threshold と rootMargin の設定を調整し、挙動が変わるかを確認しました。
threshold と rootMargin とは?
useInView では、要素がどの程度見えているか、どの範囲を監視するかを設定できます。
-
threshold:要素がどの程度見えたら、状態の変化を通知するかを指定します。0.1は、要素の面積の約10%が監視範囲と重なった状態が判定の目安になります。 -
rootMargin:監視範囲の上下左右に余白を追加・縮小するための設定です。'0px'は範囲を変更せず、'-40% 0px'は上下方向の余白をマイナスに設定します。
補足:rootMarginのパーセント値について
W3C の Intersection Observer 仕様 の文面では、rootMargin のパーセント値は「矩形の幅を基準に解決される」と書かれています。
一方、手元のブラウザ(Google Chrome 155.0.8059.40)で、窓サイズ 1131×1091 の状態で試したところ、rootMargin: '-40% 0px' の判定範囲は、上から約 440〜650px(高さの約40〜60%)になりました。幅基準であれば約 450〜630px になるはずなので、このブラウザでは上下のパーセント値は高さを基準に計算されていると判断しました。
そのため、この記事では「画面中央の約20%の帯を判定範囲にした」という前提で説明しています。ブラウザや仕様の更新で、挙動が変わる可能性があります。
今回の検証では、apps と about の両方が監視範囲内にある境目でスクロール方向を切り替え、現在地がどのように更新されるかを比較しました。
この条件をA・B・Cで統一し、threshold と rootMargin の調整、および現在地の管理方法によって挙動が変わるかを確認します。
5. 解決するまでに試したこと
A. threshold を 0.1 に設定する
最初は、threshold を 0.1、rootMargin を '0px' に設定しました。
const { ref } = useInView({
threshold: 0.1,
rootMargin: '0px',
onChange(inView) {
if (inView) {
onInView(id, true);
}
},
});
この設定では、セクションが監視範囲と約10%以上重なると、inView が true になるため、隣のセクションが見え始めた段階で現在地が切り替わることがありました。
また、スクロールして戻ったときに現在地が正しく更新されない問題も発生しました。
Aの動作デモ
history → apps → about の順にスクロールし、apps と about の両方が監視範囲内にあるところで折り返して上に戻ります。
上方向にスクロールすると、監視範囲内のセクションは次のように変化します。
apps, about → apps → history
しかし、現在地は次のように変化しました。
about → about → history
本来は apps が監視範囲内にある間に現在地も apps に切り替わってほしいところですが、途中のセクションが飛ばされてしまいます。
B. rootMargin を変更する
次に、切り替わるタイミングを調整するため、threshold と rootMargin を変更しました。
const { ref } = useInView({
threshold: 0,
rootMargin: '-40% 0px',
onChange(inView) {
if (inView) {
onInView(id, true);
}
},
});
Bの動作デモ
rootMargin を変更し、セクションを監視する範囲を調整しました。
しかし、上方向にスクロールすると、監視範囲内に apps が含まれているにもかかわらず、現在地は about のままでした。
監視範囲内:apps, about → apps → history
現在地 :about → about → history
このことから、監視範囲を調整するだけでは、今回の問題を解決できないことが分かりました。
C. true / false の両方を受け取り、配列で管理する
A・Bでは、inView が true の場合だけ親コンポーネントに通知していました。
この問題についてClaude Codeに相談したところ、true と false の両方を親コンポーネントに通知し、監視範囲内にあるセクションを配列で管理する方法を提案してもらいました。
そこで、この方法を実装し、実際に挙動が改善するかを検証しました。
まず、各セクションの表示状態を親コンポーネントに通知します。
const { ref } = useInView({
threshold: 0,
rootMargin: '-40% 0px',
onChange(inView) {
onInView(id, inView);
},
});
次に、inView の値に応じて配列を更新します。
const handle = (id: string, inView: boolean) => {
setVisible((prev) => [
...new Set(
inView
? [...prev, id]
: prev.filter((item) => item !== id)
),
]);
};
-
inView === trueの場合:セクションを配列に追加する。 -
inView === falseの場合:セクションを配列から削除する。
こうすることで、現在監視範囲内にあるセクションを把握できるようになります。
最後に、配列をセクションの順番に並べ、先頭のセクションを現在地として扱います。
useEffect(() => {
if (visible.length > 0) {
setCurrentId(sortSections(visible)[0]);
}
}, [visible]);
sortSections()について
sortSections() では、配列にあるセクションをページ上の順番に並べ替えています。その先頭のセクションを現在地として扱います。
const NOT_FOUND = 3;
const SECTIONS = ['history', 'apps', 'about'] as const;
const SectionMap = new Map<string, number>(
SECTIONS.map((item, index) => [item, index])
);
const sortSections = (sections: string[]): string[] => {
return [...sections].sort((a, b) => {
const itemA = SectionMap.get(a) ?? NOT_FOUND;
const itemB = SectionMap.get(b) ?? NOT_FOUND;
return itemA - itemB;
});
};
この処理により、複数のセクションが同時に監視範囲内にある場合も、ページ上の順番を基準に現在地を決められます。
これにより、スクロール方向や通知された順番だけに頼らず、監視範囲内にあるセクションの配列から現在地を決められるようになりました。
Cの動作デモ
A・Bと同様に、history → apps → about の順にスクロールし、apps と about の両方が見えているところで折り返して上に戻ります。
Cでは、apps と about が監視範囲内にあるときも現在地は apps のままになり、その後 apps のみが監視範囲内にある状態では apps、さらに history まで戻ると history が現在地として表示されました。
監視範囲内:apps, about → apps → history
現在地 :apps → apps → history
このように、inView が true の場合だけでなく、false の場合も親コンポーネントに通知することで、上方向にスクロールした際も現在地が適切に切り替わるようになりました。
6. なぜ改善したのか
A・B では、親は現在地を1つの id(currentId)としてしか持っておらず、inView が true の通知が来たときだけ更新していました。そのため、セクションが監視範囲から外れた(false になった)ことは、親に伝わりませんでした。
onChange は、inView が変わったときだけ呼ばれます。たとえば apps と about が同時に監視範囲内にあり、about だけが出ていったとき、about の onChange は false で呼ばれます。一方、apps は true のまま変わらないので、apps の onChange は呼ばれません。そのため、A・B では親が「どのセクションが、いま監視範囲内にあるか」を知る手段がありませんでした。
Cでは、true と false の両方を使って配列を更新するようにしました。これにより、監視範囲に入ったセクションを追加し、監視範囲から外れたセクションを削除できるようになりました。
この変更によって、監視範囲内にあるセクションの状態を正しく管理できるようになり、今回の問題解決につながりました。
補足:sortSections() の有無による違い
sortSections() の有無によって、現在地の切り替わり方に違いが見られました。
監視範囲内にあるセクションの配列と、それに対応する現在地の変化を比較します。
監視範囲内にあるセクション
history → history, apps → apps → apps, about → apps → apps, history → history
sortSections() ありの場合
history → history → apps → apps → apps → history → history
sortSections() なしの場合
history → history → apps → apps → apps → apps → history
このように、sortSections() の有無によって、現在地の切り替わり方に違いがありました。
sortSections() は、監視範囲内に複数のセクションがある場合に、ページ上の順番を基準に現在地を決めるための処理です。
なお、sortSections() の有無によって挙動に違いはありましたが、今回の A・B で発生した問題の解決には、false も親コンポーネントに通知することと、監視範囲内にあるセクションの id を配列で持つことの両方が必要でした。
7. まとめ
今回は、useInView で目次の現在地を管理する際に起きた問題と、その改善方法を紹介しました。
-
onChangeは、inViewが変わったときだけ呼ばれる - 複数のセクションが同時に監視範囲内にあるとき、片方が出ていっても、残ったほうには通知が来ない
- そのため、
trueとfalseの両方を親に通知し、範囲内のセクションの id を配列で持つ必要があった


