IntersectionObserver 実践ガイド ― scrollイベントを卒業して、要素の表示を賢く検知する
scrollイベントを毎フレーム監視してgetBoundingClientRectを呼ぶ時代は終わりました。IntersectionObserverなら、要素が画面に入ったかをブラウザが効率的に教えてくれます。

scrollイベントで頑張ると、なぜ重くなるのか
「要素が画面に入ったらフェードインさせたい」「画像はスクロールで近づいてから読み込みたい」——フロントエンドでよくある要件です。少し前までは、これを window.addEventListener('scroll', ...) で実現していました。スクロールするたびに getBoundingClientRect() で要素の位置を測って、画面内に入っているか自前で判定する。書けば動くのですが、これがけっこう重いんです。
scrollイベントは1秒間に何十回も発火します。その上 getBoundingClientRect() はレイアウト計算を強制するため、要素が増えると目に見えてカクつく。throttleやrequestAnimationFrameで軽減はできますが、それでも「メインスレッドを使って毎回判定する」という構造は変わりません。
ここで登場するのが IntersectionObserver です。これは「要素が画面(または別の要素)と交差したかどうか」を、ブラウザのレンダリングエンジン側で監視してくれるAPI。判定処理はメインスレッドの外で動くので、軽くて、しかもscrollイベントのバッドプラクティスをほぼ全部解決してくれます。
最小構成:observe して、入ってきたら何かする
使い方の基本は3ステップ。observerを作って、observeしたい要素を登録して、コールバックで何をするか書く。それだけです。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
});
/* 監視したい要素を登録 */
document.querySelectorAll('.fade-in').forEach((el) => {
observer.observe(el);
});
コールバックには entries(交差状態が変わった要素の配列)が渡されます。各エントリの isIntersecting が true になっていれば、その要素は今ビューポートに入っています。逆に false なら出ていったところ。これだけで「画面に入ったらクラスを付ける」が実装できます。
下のボックスをスクロールしてみてください。緑のカードが画面に入った瞬間にフェードインします。
このデモには root と threshold というオプションが付いています。次のセクションでこのオプションを掘り下げます。
現場で必須のオプション3つ:root / rootMargin / threshold
IntersectionObserverのコンストラクタには第2引数でオプションを渡せます。実務では基本的にこの3つだけ覚えておけば困りません。
const observer = new IntersectionObserver(callback, {
/* 何と交差しているかを判定する基準(省略時はビューポート) */
root: document.querySelector('.scroll-area'),
/* 判定範囲を内側/外側に広げるマージン。CSSと同じ書き方 */
rootMargin: '0px 0px -100px 0px',
/* どれくらい入ったら "交差中" とみなすか(0〜1、または配列) */
threshold: 0.3
});
root は判定の基準となる要素。指定しなければビューポート(画面全体)が基準ですが、スクロールするコンテナ自身を指定すれば、その内部のスクロールに対して交差判定ができます。先ほどのデモではボックス内スクロールだったので、root にスクロール領域を渡していました。
rootMargin は「判定エリアを広げる/狭める」ためのマージン。'0px 0px -100px 0px' なら下端を100px内側に縮めるので、要素が画面下から100px入った時点で「交差中」になります。フェードインのトリガーを少し早く・遅くしたいときに便利です。
threshold は「どれくらい重なったら交差とみなすか」の閾値。0 なら1pxでも入った瞬間、1 なら完全に入りきった時、0.5 なら半分入った時。配列で [0, 0.5, 1] のように複数指定すると、複数のタイミングでコールバックが呼ばれます。
パターン1:画像の遅延読み込み(Lazy Loading)
画像を実際に画面に近づいてから読み込むと、初期表示が軽くなりますし、ユーザーが見ない画像のダウンロードも省けます。ネイティブの loading="lazy" 属性もありますが、独自の演出(フェードインさせながらロード、低解像度→高解像度の差し替え、など)を入れたいときはIntersectionObserverの出番です。
const lazyIo = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src;
img.classList.add('is-loaded');
/* 一度ロードしたら監視を解除 */
obs.unobserve(img);
});
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach((img) => {
lazyIo.observe(img);
});
2つのコツがあります。1つ目は rootMargin: '200px'。画面に入る200px手前で読み込みを開始しておけば、ユーザーがスクロールして到達したときには画像はもう表示されています。「ちょうど見えた瞬間にロードが始まる」より自然な体験になります。
2つ目は unobserve()。一度ロードしたら、その要素はもう監視する必要がありません。明示的に外しておくと、observerが管理する要素数が減ってメモリにも優しくなります。「一度発火したら終わり」系の処理では必ず unobserve しましょう。
下のグレーのプレースホルダーは、画面に近づいたタイミングで「ロード完了」状態に変わります。一度切り替わったあとはobserveを解除しています。
パターン2:無限スクロール
リストの最下部にダミー要素(センチネル)を置いて、それが画面に入ったら次のページを読み込む。これも無限スクロールの定番パターンで、IntersectionObserverと相性抜群です。
const sentinel = document.querySelector('.sentinel');
const io = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting) return;
const items = await fetchNextPage();
renderItems(items);
if (isLastPage()) io.unobserve(sentinel);
});
io.observe(sentinel);
大事なのは「リストの最後に1つだけ監視対象を置く」という考え方です。アイテム1つ1つを監視するのではなく、末尾のセンチネルが見えそうになったら「そろそろ次を取りに行く」というシンプルな構造。最後のページまで読んだら unobserve で監視終了。
下までスクロールするとアイテムが追加されます。20件で打ち止めになり、それ以降は監視解除されます。
パターン3:目次のスクロールスパイ
長い記事に「現在地ハイライトつきの目次」を付けるパターン。スクロール位置に応じてサイドの目次項目を切り替える、あれです。これもIntersectionObserverでシンプルに書けます。
const sections = document.querySelectorAll('section[id]');
const navLinks = document.querySelectorAll('.toc a');
const spy = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
const id = entry.target.id;
navLinks.forEach((a) => {
a.classList.toggle(
'is-active',
a.getAttribute('href') === '#' + id
);
});
});
}, { rootMargin: '-40% 0px -50% 0px' });
sections.forEach((s) => spy.observe(s));
このパターンの肝は rootMargin の値です。'-40% 0px -50% 0px' は「画面の上から40%、下から50%の帯」だけを判定範囲にする、という意味。つまり「画面の中央あたり」にあるセクションだけが交差中とみなされます。普通のフェードインとは違って、「今読んでいる位置」を特定したいので、判定範囲をあえて狭めるのがポイント。
右の本文をスクロールすると、左の目次のアクティブ項目が切り替わります。
セクション1
スクロールして次のセクションに移動すると、左側の目次のハイライトが追従します。
セクション2
判定範囲を rootMargin で「画面中央の帯」に絞っているのが、自然な切り替えのコツです。
セクション3
scrollイベントで実装すると重くなりがちな処理が、ほんの数行で完結します。
セクション4
ここまでスクロールすると、目次の最後の項目がアクティブになっているはず。
導入時のチェックリスト
最後に、現場でIntersectionObserverを使うときに気をつけたいポイントをまとめておきます。仕様の落とし穴と、運用のコツの両方です。
1つ目。コールバックの初回発火について。observe() を呼んだ瞬間、要素の「現在の交差状態」が一度コールバックに通知されます。「ページ読み込み時に画面内にすでにある要素」もちゃんとフェードインしてくれるのはこのおかげ。逆に、「画面に入ってきた瞬間だけ動かしたい」場合は、初回の発火に注意が必要です。
2つ目。一度発火すれば十分な処理は unobserve() で監視解除する。フェードイン、画像の遅延ロードなどは典型例です。残しておくとスクロールのたびにコールバックが走るので、要素が多いと地味にコストになります。
3つ目。コンポーネント(ReactやVueなど)でobserverを使う場合は、unmount時に disconnect() を必ず呼ぶこと。disconnectはobserverが管理するすべての要素を一括で監視解除するメソッドです。これを忘れるとメモリリークの原因になります。
/* React の useEffect の例 */
useEffect(() => {
const io = new IntersectionObserver(callback, options);
io.observe(ref.current);
return () => io.disconnect();
}, []);
4つ目。ブラウザ対応について。IntersectionObserverはモダンブラウザではすべて対応済みで、IEを切れる現場ならポリフィル不要で使えます。root に別の要素を指定するパターンも全ブラウザで安定しています。安心して採用してください。
まとめ
- IntersectionObserverは「要素が画面(または別要素)と交差したか」を効率的に検知するAPIで、scrollイベント+getBoundingClientRectの代替として現場の標準。
- 使い方は3ステップ。observerを作る、要素を observe() する、コールバックで isIntersecting を見て処理する。
- オプションは root(基準要素)、rootMargin(判定範囲の調整)、threshold(重なり率)の3つで実務はほぼカバーできる。
- フェードイン演出は threshold: 0.1〜0.3 程度で自然に。0だと早すぎる印象になりがち。
- 遅延ロードは rootMargin を広めに取って先読みするのがコツ。一度ロードしたら unobserve() で外す。
- 無限スクロールは末尾にセンチネル要素を置く構造がシンプルで強い。リストの全アイテムを監視する必要はない。
- スクロールスパイは rootMargin: '-40% 0px -50% 0px' のように判定範囲を画面中央に絞ると、目次の切り替わりが自然になる。
- コンポーネントで使うときは unmount 時に disconnect() を呼んでクリーンアップ。これを忘れるとメモリリークの原因になる。