「スクロールして見えてきた要素をふわっと表示したい」「画面に入るまで画像を読み込みたくない」「無限スクロールで一番下に来たら次のデータを取りたい」——こうした処理を scroll イベントと getBoundingClientRect() で書くと、スクロールのたびに計算が走って重くなりがちです。IntersectionObserver は要素が表示領域に入った・出たことをブラウザーに監視してもらう API で、この手の処理をずっと簡単かつ軽く書けます。この記事では基本の書き方、root や threshold といったオプション、動くデモ、つまずきやすい点までを解説します。
目次
scroll イベントで判定する方式の問題
従来のやり方は、scroll イベントを監視して、その中で getBoundingClientRect() を呼び、要素の座標が画面内かどうかを毎回計算するというものでした。動きはしますが、スクロール中は1秒に何十回もイベントが発火するうえ、getBoundingClientRect() はブラウザーにレイアウトの再計算を要求する処理なので、監視対象が増えるほど動作がもたつきます。
IntersectionObserver は、この判定をブラウザー側に任せてしまう仕組みです。要素と表示領域の重なり(交差=intersection)が変化したときだけコールバックが呼ばれるので、スクロール中ずっと処理が走ることはありません。判定もメインスレッドの外側で行われるため、要素が多くてもスクロールの滑らかさを損ないにくいのが利点です。主要ブラウザーはすべて対応済みで、そのまま使えます。
基本の使い方
手順は2ステップです。コールバック関数を渡してインスタンスを作り、監視したい要素を observe() に渡します。
const target = document.querySelector('.card');
// 1. コールバックを渡してインスタンスを作る
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
// isIntersecting が true なら表示領域と重なっている
if (entry.isIntersecting) {
console.log('見えた', entry.target);
}
}
});
// 2. 監視を開始する
observer.observe(target);
コールバックの第1引数 entries は配列です。ひとつのインスタンスで複数の要素を監視でき、同じタイミングで状態が変わった要素はまとめて渡されるので、必ず for...of などで回して処理します。
そして重要なのが、コールバックは「入ったとき」だけでなく「出たとき」にも呼ばれることです。つまり entries の中には交差していない要素も混ざります。entry.isIntersecting で分岐せずに処理を書くと、画面外に出た瞬間にも同じ処理が動いてしまうので注意してください。
もうひとつ、observe() を呼んだ直後にも一度コールバックが実行されます。ページを開いた時点ですでに画面内にある要素も、この初回実行で isIntersecting: true として届くため、「最初から見えている要素だけ処理されない」という事態は起きません。
entry から取り出せる情報
コールバックに渡される各 entry(IntersectionObserverEntry)には、どの要素がどれくらい重なっているかの情報が入っています。
| プロパティ | 内容 |
|---|---|
target | 状態が変化した要素そのもの |
isIntersecting | 表示領域と重なっているかどうかの真偽値 |
intersectionRatio | 要素のうち重なっている割合(0〜1) |
boundingClientRect | 監視対象の要素の矩形 |
intersectionRect | 実際に重なっている部分の矩形 |
rootBounds | 基準となる領域(root)の矩形 |
time | 変化が記録された時刻(ページ読み込みからのミリ秒) |
ふだん使うのは target と isIntersecting のほぼ2つだけです。「半分見えたら」のような細かい制御をしたいときに intersectionRatio を使いますが、後述の threshold オプションで発火の条件そのものを指定できるので、自分で割合を比較する場面は多くありません。
スクロールで要素をフェードインさせる
実際に動かしてみましょう。カードを初期状態では透明にして少し下にずらしておき、表示領域に 20% 入ったところで is-visible クラスを付けて、CSS の transition でふわっと表示します。プレビューの枠内を下にスクロールしてみてください。
<div class="scroll-area">
<p class="lead">下にスクロールしてください</p>
<div class="card">1枚目のカード</div>
<div class="card">2枚目のカード</div>
<div class="card">3枚目のカード</div>
<div class="card">4枚目のカード</div>
</div>
.scroll-area {
height: 300px;
overflow-y: scroll;
padding: 16px;
border: 1px solid #ddd;
font-family: sans-serif;
}
.lead {
margin: 0 0 240px;
color: #666;
}
.card {
margin-bottom: 32px;
padding: 32px 16px;
border-radius: 8px;
background: #f1f8ff;
color: #0b5ed7;
font-weight: bold;
text-align: center;
/* 初期状態:透明で少し下にずらしておく */
opacity: 0;
transform: translateY(24px);
transition: opacity 0.6s, transform 0.6s;
}
/* JavaScript から付けられる、表示状態のクラス */
.card.is-visible {
opacity: 1;
transform: translateY(0);
}
const cards = document.querySelectorAll('.card');
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
// 交差していない(画面外の)要素は無視する
if (!entry.isIntersecting) continue;
entry.target.classList.add('is-visible');
// 一度表示したら監視をやめる
observer.unobserve(entry.target);
}
}, {
// スクロールするのはページではなく .scroll-area なので root に指定
root: document.querySelector('.scroll-area'),
// 要素が 20% 見えたら発火させる
threshold: 0.2,
});
cards.forEach((card) => observer.observe(card));
ポイントは、一度表示したら unobserve() で監視をやめているところです。これを書かないと、スクロールで上に戻って要素が画面外に出たあと、また入ってきたときにコールバックが呼ばれ続けます。「初回だけ実行したい」処理では、この後片付けをセットで書くのが定石です。
また、アニメーションそのものは CSS の transition に任せ、JavaScript はクラスを付けるだけにしています。こうしておくと、動きの調整を CSS 側だけで完結できます。
オプションで発火のタイミングを調整する
IntersectionObserver の第2引数には、判定の基準を決めるオプションを渡せます。
| オプション | 説明 |
|---|---|
root | 交差の基準にする要素。既定は null(ブラウザーの表示領域) |
rootMargin | root の範囲を広げる・狭めるための余白。CSS の margin と同じ書式 |
threshold | どれだけ重なったら発火するかの割合(0〜1)。配列で複数指定できる |
root:ページ以外のスクロール領域を基準にする
既定ではブラウザーの表示領域が基準になります。上のデモのように overflow: scroll を持つ要素の中でスクロールする場合は、その要素を root に指定します。指定を忘れるとページ全体が基準になるため、内側のスクロールに反応しません。
const observer = new IntersectionObserver(callback, {
// このスクロールコンテナーを基準にする
root: document.querySelector('.modal-body'),
});
root に指定できるのは、監視対象の祖先要素だけです。関係のない要素を渡しても交差は検出されません。
rootMargin:手前で発火させる
rootMargin は基準領域を仮想的に拡大・縮小します。画像の遅延読み込みのように「画面に入る少し前から準備したい」ケースで役立ちます。
// 画面の下端より 200px 手前で発火させる
const observer = new IntersectionObserver(callback, {
rootMargin: '0px 0px 200px 0px', // 上 右 下 左
});
// 逆に負の値を使うと、判定を内側に狭められる
const strict = new IntersectionObserver(callback, {
rootMargin: '-100px 0px', // 上下 100px ぶん内側に入ってから発火
});
書式は CSS の margin と同じで、正の値で外側に広がり、負の値で内側に狭まります。ただし単位は px か % のみで、rem や単位なしの 0 は使えません。'0 0 200px 0' のように単位を省くとエラーになるので、0px と書きます。
threshold:どれだけ見えたら発火するか
threshold は 0〜1 の割合で、既定は 0——つまり1ピクセルでも重なった瞬間に発火します。0.5 にすれば半分見えたとき、1.0 にすれば全体が入ったときです。
// 要素が半分見えたら発火
const observer = new IntersectionObserver(callback, { threshold: 0.5 });
// 配列で複数のタイミングを指定する(見え具合に応じて濃さを変える例)
const fader = new IntersectionObserver((entries) => {
for (const entry of entries) {
entry.target.style.opacity = entry.intersectionRatio;
}
}, {
threshold: [0, 0.25, 0.5, 0.75, 1],
});
配列で渡すと、それぞれの割合をまたぐたびにコールバックが呼ばれます。細かい段階を指定するほど発火回数が増えるので、必要な粒度にとどめておくのが無難です。
無限スクロールで次のページを読み込む
実用例として、リストの末尾に置いた目印の要素(センチネル)を監視し、それが見えたら次のデータを取得する形を見てみましょう。scroll イベントで位置を計算する必要がなくなります。
const list = document.querySelector('#list');
const sentinel = document.querySelector('#sentinel'); // リスト末尾の空要素
let page = 1;
let loading = false;
const observer = new IntersectionObserver(async (entries) => {
const entry = entries[0];
// 見えていない、または読み込み中なら何もしない
if (!entry.isIntersecting || loading) return;
loading = true;
const res = await fetch(`/api/posts?page=${page}`);
const posts = await res.json();
if (posts.length === 0) {
// これ以上データがないので監視を終了する
observer.disconnect();
return;
}
for (const post of posts) {
const li = document.createElement('li');
li.textContent = post.title;
list.appendChild(li);
}
page += 1;
loading = false;
}, {
rootMargin: '0px 0px 300px 0px', // 300px 手前で読み込みを始める
});
observer.observe(sentinel);
loading フラグで多重リクエストを防いでいるのがポイントです。センチネルが表示領域に入ったままだと、読み込みの前後で再びコールバックが呼ばれる可能性があるため、通信中は処理をスキップします。データが尽きたら disconnect() で監視ごと止めています。
監視をやめる(unobserve と disconnect)
解除の方法は2つあります。特定の要素だけ外すなら unobserve(target)、そのインスタンスの監視をすべて止めるなら disconnect() です。
// 特定の要素の監視だけをやめる(初回だけ実行したいとき)
observer.unobserve(entry.target);
// すべての監視をやめる(インスタンスは再利用できる)
observer.disconnect();
React などコンポーネントが付いたり消えたりする作りでは、useEffect のクリーンアップ関数で disconnect() を呼びます。監視を残したままにすると、削除済みの要素に対する参照が残り続けます。
思ったタイミングで発火しないとき
設定は合っているはずなのにコールバックが呼ばれない、あるいは呼ばれすぎる。原因はいくつかのパターンに絞れます。
要素にサイズがない
いちばん多いのがこれです。中身が空の div や、display: none の要素は高さが 0 なので、交差が発生しません。無限スクロールのセンチネルが動かないときは、height: 1px でもよいので実体を持たせてください。
同様に、threshold: 1.0 を指定した要素が表示領域より大きい場合も発火しません。全体が入りきることが物理的にないためです。縦に長いセクションを監視するなら threshold は小さめにするか、0 のままにしておきます。
isIntersecting で分岐していない
「画面外に出たときにもアニメーションが動く」「読み込みが2回走る」といった症状は、たいてい isIntersecting の判定を書き忘れています。前述のとおりコールバックは出入りの両方で呼ばれるので、入ったときだけ処理したいなら必ず条件分岐を入れます。
rootMargin の単位が抜けている
rootMargin に '0 0 200px 0' のように単位なしの値を混ぜると、ブラウザーが値を解釈できずエラーになります。CSS では 0 に単位が不要ですが、ここでは必要です。すべての値に px か % を付けてください。
root に祖先でない要素を指定している
root は監視対象の祖先要素である必要があります。兄弟要素やまったく別の場所にある要素を渡しても交差は検出されません。設定したのに一度も発火しないときは、DOM 構造上その要素が本当に親子関係にあるかを確認します。
画像の遅延読み込みには loading=”lazy” も検討する
IntersectionObserver の代表的な用途に画像の遅延読み込みがありますが、単に「画面に近づいたら読み込む」だけであれば、img 要素の loading="lazy" 属性で JavaScript なしに実現できます。主要ブラウザーが対応しており、記述もこれだけです。
<img src="/images/photo.jpg" alt="写真" width="800" height="600" loading="lazy">
IntersectionObserver を使う価値があるのは、読み込みのタイミングを自分で制御したいときや、画像以外の処理(動画の再生、アニメーションの開始、計測イベントの送信など)を絡めたいときです。素直な遅延読み込みなら属性ひとつで済ませたほうが確実です。
まとめ
IntersectionObserver は、要素が表示領域と重なったかどうかをブラウザーに監視させる API です。new IntersectionObserver(コールバック, オプション) でインスタンスを作り、observe(要素) で監視を始める。コールバックの中で entry.isIntersecting を見て分岐する。基本はこれだけで、scroll イベントでの座標計算はもう必要ありません。
実装で押さえるべきは3点です。コールバックは出入りの両方で呼ばれるので isIntersecting で必ず分岐すること、一度きりの処理なら unobserve() で片付けること、そして監視対象にはサイズがあること。この3つを意識しておけば、フェードイン・無限スクロール・遅延読み込みといった定番の実装はすっきり書けます。