IntersectionObserver 実践ガイド ― scrollイベントを卒業して、要素の表示を賢く検知する

scrollイベントを毎フレーム監視してgetBoundingClientRectを呼ぶ時代は終わりました。IntersectionObserverなら、要素が画面に入ったかをブラウザが効率的に教えてくれます。

scrollイベントで頑張ると、なぜ重くなるのか

「要素が画面に入ったらフェードインさせたい」「画像はスクロールで近づいてから読み込みたい」——フロントエンドでよくある要件です。少し前までは、これを window.addEventListener('scroll', ...) で実現していました。スクロールするたびに getBoundingClientRect() で要素の位置を測って、画面内に入っているか自前で判定する。書けば動くのですが、これがけっこう重いんです。

scrollイベントは1秒間に何十回も発火します。その上 getBoundingClientRect() はレイアウト計算を強制するため、要素が増えると目に見えてカクつく。throttleやrequestAnimationFrameで軽減はできますが、それでも「メインスレッドを使って毎回判定する」という構造は変わりません。

ここで登場するのが IntersectionObserver です。これは「要素が画面(または別の要素)と交差したかどうか」を、ブラウザのレンダリングエンジン側で監視してくれるAPI。判定処理はメインスレッドの外で動くので、軽くて、しかもscrollイベントのバッドプラクティスをほぼ全部解決してくれます。

📌 ポイント
IntersectionObserverは「scrollイベント+getBoundingClientRect」を置き換えるためのAPI。ブラウザが内部で効率的に交差判定をしてくれるので、要素が増えてもパフォーマンスが落ちにくい。フェードイン、遅延読み込み、無限スクロール、目次のスクロールスパイ——どれも本来これで書くべき処理です。

最小構成:observe して、入ってきたら何かする

使い方の基本は3ステップ。observerを作って、observeしたい要素を登録して、コールバックで何をするか書く。それだけです。

最小限のIntersectionObserver
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(交差状態が変わった要素の配列)が渡されます。各エントリの isIntersectingtrue になっていれば、その要素は今ビューポートに入っています。逆に false なら出ていったところ。これだけで「画面に入ったらクラスを付ける」が実装できます。

▶ Live Demo — スクロールでフェードイン

下のボックスをスクロールしてみてください。緑のカードが画面に入った瞬間にフェードインします。

↓ スクロールしてください
入ってくるとフェードイン #1
続きはこの下
入ってくるとフェードイン #2
もう一つあります
入ってくるとフェードイン #3
ここで終わり

このデモには rootthreshold というオプションが付いています。次のセクションでこのオプションを掘り下げます。

現場で必須のオプション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] のように複数指定すると、複数のタイミングでコールバックが呼ばれます。

💡 現場の経験則
フェードイン演出は threshold: 0.1〜0.3 あたりが自然に感じます。0だと「画面端に1pxかすった瞬間」に発火するので、ユーザーが「ちゃんと見えた」と感じる前にアニメーションが始まってしまいます。少しだけ要素が見えてから動くほうが、目に馴染みます。

パターン1:画像の遅延読み込み(Lazy Loading)

画像を実際に画面に近づいてから読み込むと、初期表示が軽くなりますし、ユーザーが見ない画像のダウンロードも省けます。ネイティブの loading="lazy" 属性もありますが、独自の演出(フェードインさせながらロード、低解像度→高解像度の差し替え、など)を入れたいときはIntersectionObserverの出番です。

data-srcから本物のsrcに差し替えるパターン
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 しましょう。

▶ Live Demo — 遅延ロードのシミュレーション

下のグレーのプレースホルダーは、画面に近づいたタイミングで「ロード完了」状態に変わります。一度切り替わったあとはobserveを解除しています。

↓ スクロールしてください
画像プレースホルダー A
画像プレースホルダー B
画像プレースホルダー C
ここで終わり

パターン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 で監視終了。

▶ Live Demo — スクロールで自動追加

下までスクロールするとアイテムが追加されます。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%の帯」だけを判定範囲にする、という意味。つまり「画面の中央あたり」にあるセクションだけが交差中とみなされます。普通のフェードインとは違って、「今読んでいる位置」を特定したいので、判定範囲をあえて狭めるのがポイント。

▶ Live Demo — スクロールで目次がハイライト

右の本文をスクロールすると、左の目次のアクティブ項目が切り替わります。

セクション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() を呼んでクリーンアップ。これを忘れるとメモリリークの原因になる。