- 技術 💻
IntersectionObserver使い方|無限スクロール・遅延読込レシピ集
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期連続の黒字決算。