IntersectionObserver(インターセクション・オブザーバー)とは、要素が画面(または指定した枠)に入った・出たことを、ブラウザが通知してくれる JavaScript の API です。new IntersectionObserver(コールバック, オプション) で監視役を作り、observe(要素) で監視を始めると、要素の見え方が変わったときだけコールバックが呼ばれます。スクロールのたびに位置を計算する必要がないので、フェードイン、無限スクロール、画像の遅延読み込みなどを軽く書けます。

この記事では、オプションと entry(通知の中身)の早見表、そのまま貼って使えるレシピ6種(フェードイン・無限スクロール・遅延読み込み・動画の自動再生・目次のハイライト・表示回数の計測)に加えて、上位の解説記事にはない検証として、Playwright 1.56.1(Chromium 141.0.7390.37)でスクロールしながら「どの位置で・何回・どの ratio で」コールバックが呼ばれたかを threshold と rootMargin を変えて実測した表、scroll イベントとの呼ばれる回数の比較を載せます。

IntersectionObserver の早見表

基本形はこれだけです。コールバックは「監視を始めた直後に1回」と「しきい値(threshold)をまたいだとき」に呼ばれます。

const observer = new IntersectionObserver((entries, observer) => {
  for (const entry of entries) {
    if (entry.isIntersecting) {
      // 画面に入った
    } else {
      // 画面から出た
    }
  }
}, { root: null, rootMargin: '0px', threshold: 0 });

observer.observe(document.querySelector('#target'));

オプション一覧(MDN「IntersectionObserver() constructor」で確認)

オプション 型・既定値 意味 よく使う値
root 要素または Document/既定はビューポート 交差を判定する「枠」。指定した要素はターゲットの祖先である必要がある null(画面)、横スクロールのリスト要素
rootMargin 文字列/"0px 0px 0px 0px" 枠を広げる(正の値)・狭める(負の値)。CSS の margin と同じ順(上 右 下 左)。単位は px と % のみ "200px 0px"(手前で読み込み)、"-50% 0px"(画面中央の線)
scrollMargin 文字列/"0px 0px 0px 0px" ターゲットまでの経路にある各スクロールコンテナに足す余白。入れ子のスクロール内の要素を早めに検知する "100px"
threshold 数値または数値の配列(0〜1)/0 コールバックを呼ぶ「見えている割合」のしきい値 0、0.5、[0, 0.25, 0.5, 0.75, 1]
delay ミリ秒/0 通知の最小間隔。trackVisibility: true のときは最小 100 100
trackVisibility 真偽値/false 他の要素に覆われていないか、透明度や変形がないかまで調べる(重い処理) 表示回数の厳密な計測など

entry(IntersectionObserverEntry)のプロパティ

プロパティ 内容 使いどころ
isIntersecting 枠と交差していれば true 入った・出たの判定(ほとんどこれで足りる)
intersectionRatio 見えている割合(0〜1)。intersectionRect ÷ boundingClientRect 50% 以上見えたら計測、など
intersectionRect 実際に見えている部分の矩形 見えている高さの計算
boundingClientRect ターゲット全体の矩形(getBoundingClientRect() と同じ) 上から入ったか下から入ったかの判定(top の符号)
rootBounds 枠(root + rootMargin)の矩形 rootMargin の効き方の確認
target 対象の要素 複数要素を1つの observer で監視するとき
time 交差が記録された時刻(ミリ秒) 何秒見えていたかの計算
isVisible trackVisibility 使用時に、覆われずに見えているか Chromium 141 で存在を確認(MDN の entry 一覧には未掲載)

メソッド一覧

メソッド 動き 使う場面
observe(el) 監視を始める(直後に1回コールバック) 要素を追加したとき
unobserve(el) その要素だけ監視をやめる 1回だけ処理すればよい要素(遅延読み込み、フェードイン)
disconnect() すべての監視をやめる 画面を離れるとき、無限スクロールの最後まで読んだとき
takeRecords() まだコールバックに渡していない entry を取り出す disconnect の直前に取りこぼしを処理するとき

やりたいこと別の逆引き

やりたいこと 設定 レシピ
見えたらふわっと表示 threshold: 0.1 + unobserve レシピ1
下まで来たら次のページを読み込む 番兵要素 + rootMargin: '0px 0px 300px 0px' レシピ2
画像を手前で読み込む rootMargin: '200px 0px' + unobserve(多くは loading="lazy" で足りる) レシピ3
見えている間だけ動画を再生 threshold: 0.5 で play/pause レシピ4
読んでいる見出しを目次で強調 rootMargin: '0px 0px -70% 0px' レシピ5
50% 以上・1秒以上見えたら表示1回 threshold: 0.5 + タイマー レシピ6

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「IntersectionObserver使い方|無限スクロール・遅延読込レシピ集」

まず理解したい3つのポイント

1. コールバックは「状態が変わったとき」だけ呼ばれます。scroll イベントのようにスクロールのたびに呼ばれるわけではありません。後の実測では、同じスクロールで scroll イベントが150回、IntersectionObserver が7回でした。

2. observe した直後に1回呼ばれます。画面の外にある要素でも、最初の1回は isIntersecting: false で届きます。そのため「呼ばれたら処理する」ではなく、必ず isIntersecting を見て分岐します。

3. 判定は描画のタイミングで行われます。一瞬で大きく飛ぶスクロール(プログラムからの scrollTo など)では、途中の要素が一度も交差しないことがあります。後の「無限スクロールと遅延読み込みの実行結果」で、この動きを確認しています。

flowchart LR
  A[observe 要素] --> B[直後に1回 コールバック]
  B --> C{スクロール・レイアウト変化}
  C -->|しきい値をまたいだ| D[コールバック entries]
  C -->|またいでいない| C
  D --> E{isIntersecting}
  E -->|true| F[入ったときの処理]
  E -->|false| G[出たときの処理]

実測:threshold と rootMargin でコールバックはどう変わるか

高さ 200px の要素を、ページの 2000px の位置に置き、0px から 3000px まで 50px ずつスクロールして、コールバックが呼ばれた位置と内容を記録しました。画面の高さは 600px です。つまり、スクロール位置 1400px で要素の上端が画面の下端に届き、2200px で要素の下端が画面の上端を過ぎます。

// measure.mjs(Node.js 22.22 / playwright 1.56.1)抜粋
await page.evaluate((opt) => {
  window.__log = [];
  window.__io = new IntersectionObserver((entries) => {
    for (const e of entries) window.__log.push({
      y: Math.round(scrollY), hit: e.isIntersecting, ratio: +e.intersectionRatio.toFixed(2) });
  }, opt);
  window.__io.observe(document.querySelector('#target'));
}, { threshold: [0, 0.5, 1] });

for (let y = 0; y <= 3000; y += 50) {
  // スクロールして、2フレーム待つ
  await page.evaluate((y) => new Promise(r => {
    scrollTo(0, y); requestAnimationFrame(() => requestAnimationFrame(r)); }), y);
}

実行結果(Chromium 141.0.7390.37):

設定 呼ばれた回数 呼ばれたスクロール位置と内容
threshold: 0 3 0px:out 0 / 1400px:in 0 / 2250px:out 0
threshold: [0, 0.5, 1] 7 0px:out 0 / 1400px:in 0 / 1500px:in 0.5 / 1600px:in 1 / 2050px:in 0.75 / 2150px:in 0.25 / 2250px:out 0
threshold: 0 + rootMargin: '200px 0px' 3 0px:out 0 / 1200px:in 0 / 2450px:out 0
rootMargin: '-50% 0px'(画面中央の線) 3 0px:out 0 / 1700px:in 0 / 1950px:out 0

この表から分かることをまとめます。

  • 0px の1回目は observe 直後の通知です。画面外なので out 0 で届きます。
  • threshold: 0 は「端が触れた」時点で isIntersecting: true、ratio は 0 です。1400px は要素の上端と画面の下端がちょうど接した位置で、見えている面積は 0 でも「交差している」と判定されています。ratio だけで判定すると、ここを取りこぼします。
  • 配列の threshold は、またいだしきい値ごとに呼ばれます。出ていくときは 0.75、0.25 のように、50px 刻みのスクロールでしきい値をまたいだ直後の値が届きます。ちょうど 0.5 で届くわけではないので、ratio === 0.5 のような比較はせず >= で判定します。
  • rootMargin: '200px 0px' で、入る判定が 200px 早く(1400→1200)、出る判定が 200px 遅く(2250→2450)なりました。画像の先読みに向いた設定です。
  • rootMargin: '-50% 0px' は、枠を画面中央の高さ 0 の線にする設定です。要素がその線を通過している間(1700〜1950px)だけ交差します。目次のハイライトに使えます。

計測は 50px 刻みなので、位置は「その刻みで初めて検知された位置」です。実際のスクロールでも、通知は描画のタイミングでまとめて判定されます。

不正なオプションのエラー文

threshold: 1.5   → RangeError: Failed to construct 'IntersectionObserver': Threshold values must be numbers between 0 and 1
rootMargin: '10em' → SyntaxError: Failed to construct 'IntersectionObserver': rootMargin must be specified in pixels or percent.

rootMargin に em や rem は使えません。px か % で書きます。同じページで、scrollMargin・delay・trackVisibility を指定した observer を作ると、Chromium 141 ではそれぞれ "10px 10px 10px 10px"・100・true として読み戻せました。

レシピ1:スクロールでふわっと表示(フェードイン)

一度表示したら unobserve で監視をやめるのがポイントです。CSS でアニメーションを書き、JavaScript はクラスを付けるだけにします。

<style>
  .reveal { opacity: 0; transform: translateY(24px); transition: opacity .6s, transform .6s; }
  .reveal.is-visible { opacity: 1; transform: none; }
  @media (prefers-reduced-motion: reduce) {
    .reveal { opacity: 1; transform: none; transition: none; }
  }
</style>

<section class="reveal">サービス紹介</section>
<section class="reveal">機能一覧</section>

<script>
  const revealObserver = new IntersectionObserver((entries, obs) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      entry.target.classList.add('is-visible');
      obs.unobserve(entry.target); // 1回表示したら監視終了
    }
  }, { threshold: 0.1, rootMargin: '0px 0px -10% 0px' });

  document.querySelectorAll('.reveal').forEach(el => revealObserver.observe(el));
</script>
設定 理由
threshold: 0.1 端がかすっただけで表示すると、動きが見えないまま終わる
rootMargin: '0px 0px -10% 0px' 画面の下 10% より上に来てから表示。読み手の目線に近い位置で動く
prefers-reduced-motion 動きを減らす設定の利用者には最初から表示する
JavaScript が動かない環境 .reveal を JS から付ける(document.documentElement.classList.add('js') で .js .reveal に限定する)と、内容が隠れたままにならない

レシピ2:無限スクロール(番兵要素)

リストの最後に高さ 1px の「番兵(sentinel)」要素を置き、それが見えたら次のページを読み込みます。読み込み中の二重実行防止、最後まで読んだら disconnect、画面が埋まらないときの追加読み込みまで入れた完成形です。

<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>IntersectionObserver レシピ</title>
<style>
  body { margin: 0; font-family: sans-serif; }
  .card { height: 180px; margin: 12px; padding: 12px; background: #f1f5f9; }
  .card img { width: 160px; height: 100px; background: #cbd5e1; display: block; }
  #sentinel { height: 1px; }
  #loading { text-align: center; padding: 16px; }
</style>
</head>
<body>
  <ul id="list"></ul>
  <div id="sentinel"></div>
  <p id="loading" hidden>読み込み中…</p>

<script type="module">
// 架空の API:1ページ10件、全5ページ
async function fetchArticles(page) {
  await new Promise(r => setTimeout(r, 100));
  if (page > 5) return [];
  return Array.from({ length: 10 }, (_, i) => {
    const id = (page - 1) * 10 + i + 1;
    return { id, title: `記事 ${id}`, image: `img.svg?id=${id}` };
  });
}

// --- 画像の遅延読み込み ---
const imageObserver = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target;
    img.src = img.dataset.src;   // ここで初めて読み込む
    obs.unobserve(img);          // 1回読んだら監視をやめる
  }
}, { rootMargin: '200px 0px' }); // 画面に入る 200px 手前で読み込み開始

// --- 無限スクロール ---
const list = document.querySelector('#list');
const sentinel = document.querySelector('#sentinel');
const loading = document.querySelector('#loading');
let nextPage = 1;
let isLoading = false;

async function loadMore() {
  if (isLoading) return;
  isLoading = true;
  loading.hidden = false;
  try {
    const items = await fetchArticles(nextPage);
    if (items.length === 0) {
      listObserver.disconnect();          // 最後まで読んだら監視を終了
      loading.textContent = 'すべて表示しました';
      return;
    }
    for (const item of items) {
      const li = document.createElement('li');
      li.className = 'card';
      li.innerHTML = `<img alt="" width="160" height="100" data-src="${item.image}"><span>${item.title}</span>`;
      list.append(li);
      imageObserver.observe(li.querySelector('img'));
    }
    nextPage++;
    loading.hidden = true;
  } catch (err) {
    loading.textContent = '読み込みに失敗しました。スクロールし直すと再試行します。';
  } finally {
    isLoading = false;
  }
  // 追加後も番兵が見えたまま(画面が埋まっていない)ならもう1ページ
  const rect = sentinel.getBoundingClientRect();
  if (rect.top < innerHeight + 300 && nextPage <= 5) loadMore();
}

const listObserver = new IntersectionObserver((entries) => {
  if (entries.some(e => e.isIntersecting)) loadMore();
}, { rootMargin: '0px 0px 300px 0px' }); // 下端の 300px 手前で次を読む
listObserver.observe(sentinel);
</script>
</body>
</html>

fetchArticles を自分の API 呼び出しに差し替えれば使えます。item.title を innerHTML に入れているので、サーバーから受け取る文字列を使うときは textContent で入れるか、エスケープしてから入れます。

番兵の判定で「最後に追加後も見えているか」を確認する理由

IntersectionObserver は「またいだとき」にしか呼ばれません。1ページ分を追加しても画面が埋まらず番兵が見えたままだと、状態が変わらないので次のコールバックが来ません。追加後に getBoundingClientRect() で番兵の位置を1回だけ確認し、まだ見えていれば続けて読み込みます。

無限スクロールと遅延読み込みの実行結果

上の HTML を配信し、Playwright で 1200px ずつホイールスクロールして、カード数と読み込まれた画像の数を記録しました(画面 800×600、カード1枚の高さ約 216px)。

時点 scrollY カード数 src が入った画像 画像のリクエスト数 読み込み表示
初期表示 0 10 4 4 非表示
ホイール1回目 1200 10 9 9 非表示
ホイール2回目 1581 20 10 10 非表示
ホイール4回目 3741 30 19 19 非表示
ホイール6回目 5901 40 28 28 非表示
ホイール8回目 8061 50 37 37 非表示
ホイール10回目 10221 50 45 45 すべて表示しました
ホイール12回目 10306 50 45 45 すべて表示しました
  • 50件(5ページ)を読み込んだ後、6ページ目が空だったので disconnect され、それ以上は読み込まれませんでした。
  • 画像は見えた(+200px 手前の)ものだけがリクエストされています。初期表示では10件中4枚だけでした。
  • 最後まで進んでも 50枚中 45枚で、読み込まれなかった5枚(id=5, 12, 22, 32, 42)は、1200px の一瞬のスクロールで、どの描画タイミングでも枠(画面+上下 200px)に入らなかった画像です。Playwright のホイール操作は1回で一気に移動するためで、利用者が通常どおりスクロールすると、画面に入った画像は読み込まれます。「途中を飛ばした要素は通知されない」という性質は、表示回数の計測では「見られていないものは数えない」という正しい動きになります。

レシピ3:画像の遅延読み込み(loading="lazy" との使い分け)

画像の遅延読み込みだけなら、まず HTML の loading="lazy" を使います。JavaScript は不要で、ブラウザが読み込みのタイミングを決めます。

<img src="/images/item-42.webp" loading="lazy" width="160" height="100" alt="備品の写真">

同じ条件(画面 800×600、1枚あたり約 216px のカードに画像50枚)で loading="lazy" を付けたページを開くと、初期表示で50枚中17枚が読み込まれました(Chromium 141)。画面に見えているのは3枚程度なので、ブラウザがかなり手前から先読みしていることが分かります。一方、上のレシピ(rootMargin: '200px 0px')は初期表示で4枚でした。

観点 loading="lazy" IntersectionObserver
書く量 属性1つ 数行の JavaScript
先読みの距離 ブラウザが決める(今回は約17枚分) rootMargin で自分で決める
背景画像(CSS background-image) 対象外 クラスを付けて切り替えられる
読み込み後の処理(フェードイン、計測) load イベントで個別に 同じコールバックで書ける
JavaScript が動かない環境 表示される data-src のままだと表示されない(<noscript> で補う)

判断の目安は「画像を読むだけなら loading="lazy"、先読み距離を調整したい・背景画像・読み込みと同時に別の処理をしたいなら IntersectionObserver」です。どちらの場合も width・height を書いておくと、読み込み時に周りの要素がずれません。最初の画面に見える画像(ファーストビュー)には loading="lazy" を付けず、すぐ読み込ませます。

// 背景画像の遅延読み込み
const bgObserver = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.style.backgroundImage = `url("${entry.target.dataset.bg}")`;
    obs.unobserve(entry.target);
  }
}, { rootMargin: '300px 0px' });
document.querySelectorAll('[data-bg]').forEach(el => bgObserver.observe(el));

レシピ4:見えている間だけ動画を再生する

半分以上見えたら再生、それ以外は一時停止します。自動再生はブラウザの方針により、muted と playsinline を付けたときに許可されやすくなります。

<video class="autoplay" src="/videos/demo.mp4" muted playsinline loop preload="metadata"></video>

<script>
  const videoObserver = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      const video = entry.target;
      if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
        video.play().catch(() => { /* 自動再生が許可されないときは何もしない */ });
      } else {
        video.pause();
      }
    }
  }, { threshold: [0, 0.5] });

  document.querySelectorAll('video.autoplay').forEach(v => videoObserver.observe(v));

  // タブを切り替えたときも止める
  document.addEventListener('visibilitychange', () => {
    if (document.hidden) document.querySelectorAll('video.autoplay').forEach(v => v.pause());
  });
</script>

threshold: [0, 0.5] にしているのは、0.5 をまたいだときに再生、0 をまたいで出たときに停止の両方を受け取るためです。threshold: 0.5 だけでも 0.5 を下回るときに通知は来ますが、0 も入れておくと「完全に見えなくなった」タイミングも分かります。

レシピ5:目次のハイライト(今読んでいる見出し)

枠を画面の上 30% だけにして、そこを通過した見出しを「今読んでいる見出し」とします。rootMargin: '0px 0px -70% 0px' で、画面の下 70% を判定から外します。

<nav class="toc">
  <a href="#intro">はじめに</a>
  <a href="#setup">準備</a>
  <a href="#usage">使い方</a>
</nav>
<article>
  <h2 id="intro">はじめに</h2> ...
  <h2 id="setup">準備</h2> ...
  <h2 id="usage">使い方</h2> ...
</article>

<style>.toc a.is-current { font-weight: bold; color: #2563eb; }</style>

<script>
  const links = new Map(
    [...document.querySelectorAll('.toc a')].map(a => [a.hash.slice(1), a]));

  const tocObserver = new IntersectionObserver((entries) => {
    for (const entry of entries) {
      if (!entry.isIntersecting) continue;
      for (const a of links.values()) a.classList.remove('is-current');
      links.get(entry.target.id)?.classList.add('is-current');
    }
  }, { rootMargin: '0px 0px -70% 0px' });

  document.querySelectorAll('article h2[id]').forEach(h => tocObserver.observe(h));
</script>

上の実測で見たとおり、負の rootMargin は枠を狭めます。見出しが画面上部の帯に入った瞬間に切り替わるので、見出しと本文の対応が自然になります。上に戻るスクロールでも同じ見出しが帯を通るため、正しく切り替わります。

レシピ6:記事や広告の表示回数を計測する

「50% 以上が1秒以上見えたら1回」と数える例です。見えた時刻にタイマーを開始し、1秒経つ前に見えなくなったら取り消します。

const IMPRESSION_MS = 1000;
const timers = new WeakMap();

const impressionObserver = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    const el = entry.target;
    if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
      if (timers.has(el)) continue;
      timers.set(el, setTimeout(() => {
        sendImpression(el.dataset.itemId);
        obs.unobserve(el);       // 1要素1回だけ数える
        timers.delete(el);
      }, IMPRESSION_MS));
    } else if (timers.has(el)) {
      clearTimeout(timers.get(el)); // 1秒未満で見えなくなった
      timers.delete(el);
    }
  }
}, { threshold: [0, 0.5] });

function sendImpression(id) {
  // ページを離れる直前でも送れる sendBeacon を使う
  navigator.sendBeacon('/api/impressions', JSON.stringify({ id, at: Date.now() }));
}

document.querySelectorAll('[data-item-id]').forEach(el => impressionObserver.observe(el));
工夫 理由
WeakMap でタイマーを持つ 要素が DOM から消えたとき、タイマーの記録も一緒に解放される
unobserve で1回だけ スクロールで行き来しても重複して数えない
sendBeacon ページを閉じる直前でも送信が完了しやすい
厳密に「覆われていない」まで確認したい trackVisibility: true, delay: 100 と entry.isVisible を使う(Chromium で確認。重い処理なので必要な要素だけに)

同時にタブが裏に回ったときは、visibilitychange でタイマーを取り消す処理を足すと、より正確になります。

unobserve と disconnect の使い分け、メモリリークを防ぐ書き方

「その要素の用が済んだら unobserve、画面ごと用が済んだら disconnect」と覚えると迷いません。

場面 使うもの 理由
遅延読み込み・フェードインが終わった要素 unobserve(el) 以後のコールバックが不要。他の要素の監視は続ける
無限スクロールで最後のページまで読んだ disconnect() 番兵を監視し続ける必要がない
SPA で画面(コンポーネント)を離れる disconnect() observer が要素と関数を保持し続けないようにする
リストの要素を削除する unobserve(el) してから削除 削除済みの要素への参照を残さない
動画の再生・停止、目次のハイライト 監視を続ける 入る・出るの両方に反応し続けたい

メモリリークを防ぐ書き方

  • observer は「用途ごとに1つ」作り、要素ごとに new IntersectionObserver しない。1つの observer で数百の要素を監視できます。
  • コールバックの中で大きなデータを閉じ込めない。必要な情報は el.dataset や WeakMap で要素にひも付けます。
  • SPA ではアンマウント時に必ず disconnect()。下の React フックでは useEffect の後片付けで呼んでいます。

React で使う(カスタムフック版)

React では、ref を渡すと「見えているか」を返すフックにまとめると使いやすくなります。下のコードは React 19 + TypeScript の想定で書いたもので、この記事では React 上での実行はしていません(IntersectionObserver 自体の動きは上の実測で確認しています)。

// useInView.ts
import { useEffect, useRef, useState } from 'react';

type Options = IntersectionObserverInit & { once?: boolean };

export function useInView<T extends Element>({ once = false, root = null, rootMargin = '0px', threshold = 0 }: Options = {}) {
  const ref = useRef<T | null>(null);
  const [inView, setInView] = useState(false);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;
    const observer = new IntersectionObserver(([entry]) => {
      setInView(entry.isIntersecting);
      if (entry.isIntersecting && once) observer.unobserve(entry.target);
    }, { root, rootMargin, threshold });
    observer.observe(el);
    return () => observer.disconnect();   // アンマウント時に解除
    // threshold が配列の場合は、呼び出し側で useMemo して同じ参照を渡す
  }, [once, root, rootMargin, threshold]);

  return { ref, inView };
}
// 使い方1:フェードイン
function Reveal({ children }: { children: React.ReactNode }) {
  const { ref, inView } = useInView<HTMLDivElement>({ once: true, threshold: 0.1 });
  return <div ref={ref} className={inView ? 'reveal is-visible' : 'reveal'}>{children}</div>;
}

// 使い方2:無限スクロールの番兵
function ArticleList({ items, hasMore, loadMore }: {
  items: { id: number; title: string }[]; hasMore: boolean; loadMore: () => void;
}) {
  const { ref, inView } = useInView<HTMLDivElement>({ rootMargin: '0px 0px 300px 0px' });
  useEffect(() => { if (inView && hasMore) loadMore(); }, [inView, hasMore, loadMore]);
  return (
    <>
      <ul>{items.map(a => <li key={a.id}>{a.title}</li>)}</ul>
      {hasMore && <div ref={ref} style={{ height: 1 }} />}
    </>
  );
}

React で気をつける点

点 対策
threshold を配列リテラルで渡すと毎回 observer を作り直す 定数をコンポーネントの外に置くか useMemo
loadMore が毎回新しい関数になる useCallback で包む。読み込み中フラグは親で管理
無限スクロールで inView が true のまま変わらない 1ページ追加して番兵が見えたままだと再通知されない。items.length を依存に加えて再判定するか、レシピ2のように追加後に位置を確認する
Next.js などの SSR IntersectionObserver はブラウザにしかないので、useEffect の中で作る(上のフックはこの形)

scroll イベントとの比較(呼ばれる回数の実測)

同じスクロール(ホイール 20px × 150回、0px → 3000px)で、scroll イベントと IntersectionObserver(threshold [0, 0.5, 1])のコールバック回数を数えました。

window.__cnt = { scroll: 0, io: 0 };
addEventListener('scroll', () => window.__cnt.scroll++, { passive: true });
const io = new IntersectionObserver(() => window.__cnt.io++, { threshold: [0, 0.5, 1] });
io.observe(document.querySelector('#target'));
// Playwright: page.mouse.wheel(0, 20) を150回

実行結果(Chromium 141):

wheel 150x20px {"scroll":150,"io":7,"y":3000}
方式 呼ばれた回数 位置の計算 書き方
scroll イベント 150 毎回 getBoundingClientRect() などで自分で計算(レイアウトの再計算が起きやすい) 間引き(throttle)や requestAnimationFrame を自分で足す
IntersectionObserver 7(observe 直後1回+しきい値をまたいだ6回) ブラウザが計算して entry に入れてくれる オプションを渡すだけ

「見えたか・見えなくなったか」を知りたいだけなら、IntersectionObserver のほうが呼ばれる回数が約20分の1で、計算もブラウザ任せにできます。一方、スクロール量に合わせて要素を動かす(パララックス)、進捗バーを1px単位で伸ばすといった「連続した値」が欲しい場合は、scroll イベント(または CSS のスクロール駆動アニメーション)が向いています。

やりたいこと 向いている方法
入った・出たで処理を切り替える IntersectionObserver
一定割合見えたら処理 IntersectionObserver(threshold)
スクロール量に比例して動かす scroll イベント + requestAnimationFrame、または CSS の animation-timeline: scroll()
要素のサイズ変化を知りたい ResizeObserver
DOM の追加・変更を知りたい MutationObserver

よくある質問

Q. ページを開いた直後に、画面外の要素でもコールバックが呼ばれます。 A. 仕様どおりの動きです。observe() の直後に1回、現在の状態が通知されます。実測でもスクロール位置 0 で isIntersecting: false が届きました。コールバックの中で isIntersecting を見て分岐してください。

Q. intersectionRatio > 0 で判定すると、たまに反応しません。 A. 要素の端がちょうど接した状態では、isIntersecting: true でも ratio が 0 になります(実測の 1400px の行)。また、高さ 0 の要素も ratio が 0 のままです。入った・出たの判定には isIntersecting を使います。

Q. 横スクロールのカルーセルやモーダルの中で使えますか? A. 使えます。root にスクロールする要素(カルーセルの枠など)を指定します。ターゲットはその要素の子孫である必要があります。ページ全体ではなく入れ子のスクロールの中の要素を早めに検知したいときは、scrollMargin も選択肢です(Chromium 141 で指定値が反映されることを確認)。

Q. 無限スクロールで同じページを2回読み込んでしまいます。 A. 読み込み中に番兵が出入りすると、コールバックが続けて呼ばれます。isLoading フラグで二重実行を防ぎ、最後のページまで来たら disconnect() します。レシピ2の完成形がこの形です。

まとめ

  • IntersectionObserver は 要素が枠に入った・出たことをブラウザが通知する API。判定には isIntersecting を使う
  • オプションは root・rootMargin(px と % のみ)・threshold が基本。scrollMargin・delay・trackVisibility は用途に応じて
  • 実測では rootMargin: '200px 0px' で 200px 早く検知、'-50% 0px' で画面中央の線を通過する間だけ交差 した
  • 無限スクロールは 番兵 + 二重実行防止 + 最後に disconnect + 追加後の位置確認 で安定する
  • 画像だけなら loading="lazy" が手軽。先読み距離の調整・背景画像・同時処理が必要なら IntersectionObserver
  • 用が済んだ要素は unobserve、画面ごと用が済んだら disconnect。React では useEffect の後片付けで解除する
  • 同じスクロールで scroll イベント150回に対して IntersectionObserver は7回。「見えたか」の判定は IntersectionObserver が向いている

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「IntersectionObserver使い方|無限スクロール・遅延読込レシピ集」

株式会社RJCでは、IntersectionObserver のようなブラウザ API の使い方を仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?