メインコンテンツへスキップ
Code & Craft
Web制作 約6分で読めます

Intersection Observer 実践ガイド|遅延読み込み・無限スクロール・スクロール検知

Intersection Observer API の使い方を実例で解説。画像遅延読み込み、無限スクロール、要素の可視性検知など実務で使える実装パターンを完全網羅。

スクロール位置に応じた処理は、以前は scroll イベント + getBoundingClientRect() で実装されていました。しかしこの方法はメインスレッドを毎フレーム消費し、パフォーマンスの問題を引き起こします。Intersection Observer API はこれを根本的に解決する、現代的で効率的な代替手段です。

Intersection Observer とは

Intersection Observer は、要素の可視性(ビューポートとの交差)を非同期に監視する API です。

主な特徴:

  • メインスレッドをブロックしない(別スレッドで監視)
  • 要素がビューポートに入った/出たタイミングでコールバック実行
  • 複数要素を一度に監視可能
  • rootMarginthreshold で柔軟な制御

ブラウザ対応(2026年4月)

ブラウザサポート開始
Chrome 512016年5月
Firefox 552017年8月
Safari 12.12019年3月
Edge 152017年4月

全ブラウザで完全サポート。フォールバック不要で使用できます。

基本的な使い方

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      console.log('要素が表示された:', entry.target);
    } else {
      console.log('要素が非表示になった:', entry.target);
    }
  });
});

const target = document.querySelector('.target');
observer.observe(target);

オプションの詳細

const observer = new IntersectionObserver(callback, {
  root: null,           // 監視コンテナ(null = ビューポート)
  rootMargin: '0px',    // rootの拡張/縮小
  threshold: 0.1,       // 交差率(0〜1 または配列)
});

rootMargin

ビューポートを仮想的に拡張/縮小します。CSS の margin 形式で指定します。

// ビューポートの下側を 200px 拡張
// → 下から 200px 手前で検知開始
rootMargin: '0px 0px 200px 0px'

// 上下を 100px 縮小
// → 要素が中央付近に来た時だけ検知
rootMargin: '-100px 0px'

threshold

要素がどれだけ交差したら発火するかを指定します。

// 1% 以上交差したら発火
threshold: 0.01

// 50% 交差したら発火
threshold: 0.5

// 複数のポイントで発火
threshold: [0, 0.25, 0.5, 0.75, 1]

複数指定の場合、それぞれの時点でコールバックが呼ばれます。プログレスバーや段階的アニメーションに便利です。

実践パターン1: 画像の遅延読み込み

ネイティブの loading="lazy" が使えない複雑なケースで有効です。

<img
  data-src="/images/hero.jpg"
  alt="ヒーロー画像"
  class="lazy-image"
/>
const observer = new IntersectionObserver((entries, obs) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      const img = entry.target;
      img.src = img.dataset.src;
      img.classList.add('loaded');
      obs.unobserve(img); // 一度読み込んだら監視解除
    }
  });
}, {
  rootMargin: '200px', // 画面外 200px で読み込み開始
});

document.querySelectorAll('.lazy-image').forEach(img => {
  observer.observe(img);
});
.lazy-image {
  opacity: 0;
  transition: opacity 0.3s;
}
.lazy-image.loaded {
  opacity: 1;
}

実践パターン2: 無限スクロール

ページ末尾に達したら次のページをロードします。

<div class="posts">
  <!-- 記事リスト -->
</div>
<div id="load-more-trigger"></div>
let page = 1;
let loading = false;

const trigger = document.getElementById('load-more-trigger');

const observer = new IntersectionObserver(async (entries) => {
  if (entries[0].isIntersecting && !loading) {
    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: '100px',
});

observer.observe(trigger);

実践パターン3: スクロール連動アニメーション

要素が表示されたときにフェードインさせます。

.fade-in {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s, transform 0.6s;
}

.fade-in.is-visible {
  opacity: 1;
  transform: translateY(0);
}
const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting) {
      entry.target.classList.add('is-visible');
      observer.unobserve(entry.target);
    }
  });
}, {
  threshold: 0.1,
  rootMargin: '0px 0px -50px 0px',
});

document.querySelectorAll('.fade-in').forEach(el => {
  observer.observe(el);
});

代替手段: この用途なら、CSS Scroll-Driven Animations の方が JavaScript 不要で簡潔です。

実践パターン4: 目次のアクティブ項目更新

ページスクロールに応じて、目次のアクティブ項目を更新します。

const headings = document.querySelectorAll('article h2, article h3');
const tocLinks = document.querySelectorAll('.toc a');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const id = entry.target.id;
    const link = document.querySelector(`.toc a[href="#${id}"]`);

    if (entry.isIntersecting) {
      tocLinks.forEach(l => l.classList.remove('active'));
      link?.classList.add('active');
    }
  });
}, {
  rootMargin: '-100px 0px -50% 0px',
  threshold: 0,
});

headings.forEach(heading => observer.observe(heading));

実践パターン5: 動画の自動再生/停止

動画が画面内にある時だけ再生します。

const videos = document.querySelectorAll('video');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const video = entry.target;
    if (entry.isIntersecting) {
      video.play();
    } else {
      video.pause();
    }
  });
}, {
  threshold: 0.5, // 50% 表示で再生
});

videos.forEach(video => observer.observe(video));

実践パターン6: アクティブセクションの検知

シングルページのナビゲーションで、現在表示中のセクションをハイライト:

const sections = document.querySelectorAll('section');
const navLinks = document.querySelectorAll('.nav a');

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    const id = entry.target.id;
    const link = document.querySelector(`.nav a[href="#${id}"]`);

    if (entry.isIntersecting) {
      navLinks.forEach(l => l.classList.remove('active'));
      link?.classList.add('active');
    }
  });
}, {
  threshold: 0.5,
});

sections.forEach(section => observer.observe(section));

実践パターン7: アナリティクス計測

要素が実際に表示された(=読まれた)ことを計測します。

const observer = new IntersectionObserver((entries) => {
  entries.forEach(entry => {
    if (entry.isIntersecting && entry.intersectionRatio > 0.5) {
      const id = entry.target.dataset.trackingId;

      // 表示計測を送信
      gtag('event', 'view', {
        section_id: id,
        time: Date.now(),
      });

      observer.unobserve(entry.target);
    }
  });
}, {
  threshold: [0.5],
});

document.querySelectorAll('[data-tracking-id]').forEach(el => {
  observer.observe(el);
});

entry オブジェクトのプロパティ

entry.target          // 監視対象の要素
entry.isIntersecting  // 交差しているかのbool
entry.intersectionRatio  // 交差率(0〜1)
entry.boundingClientRect // 要素の矩形情報
entry.rootBounds       // root の矩形情報
entry.time            // タイムスタンプ

パフォーマンスと最適化

監視の解除

使い終わった監視は必ず解除しましょう。

// 個別解除
observer.unobserve(element);

// 全解除
observer.disconnect();

複数の Observer を使い分け

用途ごとに Observer を分けると、オプションを最適化できます。

// 画像遅延読み込み用(マージン大)
const lazyLoadObserver = new IntersectionObserver(..., {
  rootMargin: '200px',
});

// アニメーション用(ピッタリで発火)
const animationObserver = new IntersectionObserver(..., {
  threshold: 0.1,
});

scroll イベントとの比較

項目scroll イベントIntersection Observer
パフォーマンス悪い(毎フレーム実行)良い(非同期)
メインスレッドブロックブロックしない
バッテリー消費が多い少ない
実装の簡潔さ複雑シンプル
正確性getBoundingClientRect が必要自動計算

まとめ

Intersection Observer は、以下のようなケースで特に威力を発揮します。

  • 画像・コンポーネントの遅延読み込み: パフォーマンス改善
  • 無限スクロール: ユーザー体験の向上
  • スクロール連動アニメーション: 滑らかな表示
  • アナリティクス: 正確な表示計測

Modern な Web 開発では、スクロール関連の処理はまず Intersection Observer を検討すべきです。scroll イベントを使うのは、本当にそれでなければ実現できない場合だけにしましょう。

参考リンク

#JavaScript #パフォーマンス #Web API #遅延読み込み
シェア: