IntersectionObserverの使い方を、このブログで実際に動いているコードで解説する
このブログのスクロール演出はIntersectionObserverで動いている。threshold 0.1とrootMargin -50pxにした理由、unobserveとreduced-motion対応など実物コードを軸に、遅延読み込みや無限スクロールへの応用パターンまで解説する。
このブログ(clvr.lol)のトップページは、スクロールするとセクションが下からふわっと現れる。この演出はIntersectionObserverで実装していて、コードはレイアウトファイルの末尾にある10行足らずのスクリプトだ。派手な実装ではないが、thresholdやrootMarginの値をどう決めたか、なぜunobserveしているか、といった判断は全部このコードに詰まっている。
なのでこの記事は、教科書的にAPIを網羅するのではなく、まず自分のサイトで実際に動いているコードを解体して、そこからよくある応用パターン(遅延読み込み・無限スクロールなど)に広げる構成にする。
実物: このブログのスクロール演出の全コード
Layout.astroの末尾に置いているのがこれ。ほぼ原文ママで載せる。
// RM = window.matchMedia('(prefers-reduced-motion: reduce)').matches
if (!RM) {
const obs = new IntersectionObserver((entries) => {
entries.forEach((en) => {
if (en.isIntersecting) {
en.target.classList.add('is-visible');
obs.unobserve(en.target);
}
});
}, { threshold: 0.1, rootMargin: '0px 0px -50px 0px' });
document.querySelectorAll('.reveal').forEach((el) => obs.observe(el));
} else {
document.querySelectorAll('.reveal').forEach((el) => el.classList.add('is-visible'));
}
CSS側はこれだけ。
.reveal { opacity: 0; transform: translateY(14px); transition: opacity 0.6s var(--ease-out), transform 0.6s var(--ease-out); }
.reveal.is-visible { opacity: 1; transform: none; }
@media (prefers-reduced-motion: reduce) { .reveal { opacity: 1; transform: none; transition: none; } }
.revealクラスを付けているのは今のところトップページの3つのセクションだけだが、スクリプトは全ページ共通のレイアウトに置いてあるので、任意のページでクラスを付けるだけで演出が乗る。以下、この短いコードの中の判断を1つずつ説明する。
rootMargin の下側を -50px にした理由
rootMargin: '0px 0px -50px 0px'は「ビューポートの下端を50px内側に縮める」という意味だ。つまり要素が画面下端に触れた瞬間ではなく、50px食い込んでから発火する。
最初はrootMarginなしで書いた。そうすると要素が画面に1px入った瞬間にisIntersectingがtrueになってアニメーションが始まるのだが、その時点では要素のほぼ全体がまだ画面外にある。0.6秒のtransitionが終わる頃にやっと視界に入ってくるので、ユーザーからは「すでに表示済みの要素」にしか見えず、演出が丸ごと無駄になる。50px食い込ませてから発火させると、アニメーションの大部分が視界の中で起こる。この値は厳密な計算ではなく、実際にスクロールして眺めながら決めた。
rootMarginはCSSのmarginと同じ「上 右 下 左」の順で、正の値でビューポートを広げ、負の値で縮める。遅延読み込みで「画面外200px手前から読み込み開始」とやるときは逆に正の値を使う。
threshold: 0.1 と unobserve
thresholdは「対象要素の何割が交差したら発火するか」。0.1なら要素の10%が見えた時点でコールバックが呼ばれる。デフォルトの0(1pxでも触れたら発火)より少しだけ「ちゃんと見え始めてから」に寄せている。
一度表示したらobs.unobserve(en.target)で監視を外している。この演出は一回きりでいいし、監視対象を放置しておく理由がない。逆にスクロールで出たり消えたりを繰り返したい場合(動画の自動再生・停止など)はunobserveしない、という使い分けになる。
prefers-reduced-motion を先に見る
「視差効果を減らす」を設定しているユーザーには、Observerを作ることすらせず最初からis-visibleを付けて終わりにしている。CSS側にも同じmedia queryがあるので二重の守りだが、JS側で分岐しておかないと「CSSでtransitionは消えるがopacity: 0のまま発火待ちの要素が残る」という中途半端な状態がありえる。演出系でIntersectionObserverを使うなら、この分岐はセットで入れておくべきだと思う。
APIの最小限のおさらい
実物を見たあとで仕様を確認しておく。基本形はこれだけだ。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 要素がroot(デフォルトはビューポート)と交差した
}
});
}, {
root: null, // 監視の基準。null = ビューポート
rootMargin: '0px', // 基準領域の拡張(正)・縮小(負)。CSS margin形式
threshold: 0, // 発火する交差率。0〜1、配列も可
});
observer.observe(document.querySelector('.target'));
scrollイベント + getBoundingClientRect()で同じことをやると、スクロールのたびにメインスレッドで矩形計算が走る。IntersectionObserverは交差判定をブラウザ側が非同期でやってくれるので、監視対象が増えても自前のイベントハンドラが肥大化しない。ブラウザ対応はSafari 12.1(2019年)以降すべてのモダンブラウザで揃っていて、2026年現在フォールバックを考える必要はまずない。
知っておくと混乱しないのが、コールバックはobserve直後に一度、初期状態の報告として必ず呼ばれること。画面外の要素でもisIntersecting: falseのエントリで一回走る。「observeした瞬間になぜかコールバックが動いた」と焦る前に、これは仕様だと知っておくといい。あとdisplay: noneの要素は交差判定の対象にならない点と、ビューポートより背の高い要素はintersectionRatioが1に届かないのでthreshold: 1だと永遠に発火しない点も、ハマりやすいところだ。
応用パターン1: 画像の遅延読み込み
ここからは実装パターンの解説。ただし最初に言っておくと、単純な画像の遅延読み込みならネイティブのloading="lazy"が第一選択で、IntersectionObserverの出番はない。このブログも記事内画像はほとんど使っていないので、画像遅延読み込みをIOで自前実装してはいない。
IOを使う価値があるのは、読み込みに制御を挟みたいケースだ。読み込み完了時にフェードインさせたい、CSS背景画像を遅延させたい、低解像度プレースホルダーから差し替えたい、など。
const observer = new IntersectionObserver((entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
img.addEventListener('load', () => img.classList.add('loaded'), { once: true });
obs.unobserve(img);
}
});
}, {
rootMargin: '200px 0px', // 画面外200px手前で読み込み開始
});
document.querySelectorAll('img[data-src]').forEach((img) => observer.observe(img));
演出用途とは逆に、rootMarginを正の値にして「見える前に先回りして読み込む」のがポイント。ここの値は回線が遅い環境ほど大きくしたくなるが、大きくしすぎると遅延読み込みの意味がなくなるので、200〜300px程度が定番だ。
なお、ファーストビューに入る画像を遅延読み込みにするとLCPが悪化する。画像とCore Web Vitalsの関係はCore Web Vitals改善の記事に書いた。そもそも画像が重いなら遅延させる前に圧縮した方が効くことも多くて、それ用にブラウザ完結の画像圧縮ツールを自作して使っている。
応用パターン2: 無限スクロール
リストの末尾に番兵(sentinel)となる空のdivを置いて、それが見えたら次ページを取得するパターン。
const trigger = document.getElementById('load-more-trigger');
let page = 1;
let loading = false;
const observer = new IntersectionObserver(async (entries) => {
if (!entries[0].isIntersecting || loading) return;
loading = true;
try {
const res = await fetch(`/api/posts?page=${page + 1}`);
const data = await res.json();
if (data.posts.length === 0) {
observer.unobserve(trigger); // 終端に達したら監視をやめる
return;
}
renderPosts(data.posts);
page++;
} finally {
loading = false;
}
}, { rootMargin: '300px 0px' });
observer.observe(trigger);
このパターンの要点は3つ。loadingフラグで多重フェッチを防ぐ(交差したまま複数回コールバックが走りうる)、終端に達したらunobserveする、rootMarginで先読みして「スクロールが止まる」体験を避ける。逆にこれさえ押さえれば、scrollイベントで位置計算していた頃のような throttle / debounce の調整は一切いらない。
応用パターン3: 目次のアクティブ項目ハイライト
記事のスクロール位置に応じて目次の現在地を光らせるやつ。見出しを監視して、ビューポート上部の帯に入った見出しをアクティブにする。
const headings = document.querySelectorAll('article h2, article h3');
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
document.querySelectorAll('.toc a').forEach((l) => l.classList.remove('active'));
document.querySelector(`.toc a[href="#${entry.target.id}"]`)?.classList.add('active');
});
}, {
rootMargin: '-80px 0px -70% 0px',
threshold: 0,
});
headings.forEach((h) => observer.observe(h));
ここでのrootMarginは上を-80px(固定ヘッダーの分)、下を-70%縮めていて、「ビューポート上部の細い帯」だけを判定領域にしている。負のrootMarginのわかりやすい実用例だと思う。値はヘッダーの高さとレイアウト次第なので、DevToolsで判定帯の位置を想像しながら調整することになる。
演出だけなら CSS Scroll-Driven Animations という選択肢もある
今回のスクロール演出のような「見えたらアニメーション」に限れば、CSSのanimation-timeline: view()でJavaScriptなしに書ける時代になりつつある。詳しくはCSS Scroll-Driven Animationsの記事に書いたが、対応ブラウザの足並みの問題があるので、自分はまだIntersectionObserver + クラス付与で書いている。この方式ならreduced-motion分岐もフォールバックも自分の手の内にあるし、10行程度のコードなら負債にもならない。
整理すると、自分の使い分けはこうなっている。
- 一度きりの表示演出: IntersectionObserver +
unobserve(このブログの現行実装) - 単純な画像遅延読み込み: ネイティブ
loading="lazy" - 読み込みに制御を挟む遅延読み込み・無限スクロール・可視性計測: IntersectionObserver
- スクロール量に連動する連続的なアニメーション: CSS Scroll-Driven Animations(対応状況を見ながら)
scrollイベントを自分で書くのは、この表のどれにも当てはまらないときの最後の手段だ。少なくとも「見えたかどうか」を知りたいだけなら、まずIntersectionObserverで足りる。