JavaScriptスクロール判定の新標準。Intersection Observerで低負荷なアニメーションを実装する



これまでのWebサイトでのスクロール処理では、スクロールのたびにブラウザに計算をさせる、scrollイベントを使う手法をとっていました。しかし、Webサイトの評価基準が情報の網羅性だけでなく、ユーザー体験(UX)へと変わりつつある今、その古い手法は目に見えないリスクになりつつあります。

例えば、動きをリッチにしようとスクロールアニメーションを導入した結果、画面がカクついたり(Jankの発生)、スマホでの閲覧時に動作が重くなったりした経験はないでしょうか。あるいは、scrollイベントを用いた複雑な位置計算のコードが、プロジェクトのメンテナンス性を著しく下げているケースも少なくありません。

昨今、検索エンジン(SEO)だけでなく、AIによる回答生成(AIO)や生成エンジン最適化(GEO)においても、ページの読み込み速度や描画の滑らかさは極めて重要な指標となります。不安定なスクロール処理によるUXの低下は、AIエージェントに「低品質なサイト」と判断される要因にもなり得ます。

こうした問題を解決するのが Intersection Observer API です。
ブラウザの仕組みに最適化されたこの手法は、AI時代に選ばれる軽快なサイトを作るうえで、もはや避けては通れない技術といえます。無駄な計算(技術的負債)を削ぎ落とし、今の時代に即したスムーズな実装を行うための具体的な手順を解説します。

なぜ scroll イベントは「がたつく」のか?


長年慣れ親しんできた scroll イベントと .offsetTop を組み合わせた手法。実は、モダンなWeb標準の観点から見ると、ブラウザの描画プロセスにおいては動作を重くする大きな原因となっていました。

アニメーションがカクつく現象、いわゆる「Jank(ジャンク)」が発生する裏側では、ブラウザ内部で次のようなリソースの奪い合いが起きています。

長年使ってきた従来の手法


まずは、これまで一般的だった実装をおさらいしてみましょう。
scroll イベントの中で、要素の座標(offsetTop)とスクロール量(window.scrollY)を常に引き算し続ける手法です。

JavaScript

window.addEventListener("scroll", () => {
  fadeElems.forEach((el) => {

    const ePos = el.offsetTop;
    const scroll = window.scrollY;
    const windowHeight = window.innerHeight;

    if (scroll > ePos - windowHeight + windowHeight / 5) {
      el.style.opacity = 1;
    }
  });
});


上記のコードには、3つの懸念点があります。

  1. メインスレッドの占有(計算のループ地獄)
    スクロールイベントは、スクロールの最中に数ミリ秒という極めて短いスパンで大量に発生し続けます。スクロール中に1px動くたびに、すべての対象要素に対して座標計算のループが走り、JavaScriptがメインスレッドを占有します。その結果、本来行うべき「画面の更新(描画)」が後回しにされ、コマ落ちが発生します。

  2. ブラウザを「疲れさせる」リフロー(再レイアウト)の強制
    これが最も厄介な問題です。.offsetTop や .getBoundingClientRect() を参照すると、ブラウザは「現在の正確な位置」を確定させるために、一度すべてのレイアウト計算をやり直す「リフロー」を強制されます。スクロールのたびにページ全体の再計算を強いるこの挙動は、現代の複雑なDOM構造において致命的な負荷となります。

  3. 複雑な数式(可読性の低下)
    ePos – windowHeight + windowHeight / 5 といった、直感的には理解しにくい「マジックナンバー」を含む数式が必要になります。実装者以外には意図が伝わりにくく、プロジェクト全体のメンテナンス性を著しく下げる要因になります。


このような力技での位置判定は、ユーザーの端末スペックに依存しやすく、特にモバイル環境でのUXを著しく損なう原因となっていました。

Intersection Observer が滑らかな理由


こうしたパフォーマンスの壁を打破するために登場したのが Intersection Observer API です。このAPIの最大の特徴は、開発者がJavaScriptで「計算」するのではなく、ブラウザのエンジン側に「監視」を依頼する点にあります。

ブラウザのネイティブ機能として最適化されているため、従来のコードとは比較にならないほどスムーズな挙動を実現できます。

主に以下の点が挙げられます。

  • 非同期処理による描画の優先
    要素が画面に入ったかどうかの判定は、ブラウザがメインスレッドの空き時間を見計らって非同期に行います。描画処理を邪魔しないため、アニメーション中もスクロールの滑らかさが維持されます。

  • イベント駆動による低負荷設計
    スクロール中ずっと計算し続けるのではなく、「あらかじめ指定した境界線を要素がまたいだ瞬間」だけ通知をくれる仕組みです。無駄なCPU消費を極限まで抑え、バッテリー消費にも優しい実装が可能です。

  • シンプルで管理しやすい設計
    「要素の20%が画面に入ったら処理をする」といった条件を宣言するだけで済むため、複雑な計算式を自前で書く必要がありません。これにより、実装ミスによるバグやパフォーマンスの劣化を未然に防ぐことができます。



これらを踏まえ、従来の手法とモダンな手法の違いを比較表にまとめました。
今後のWeb制作において、どちらを選択すべきかは一目瞭然です。

スクロール判定の新旧比較

※横にスクロールして確認できます

比較項目 従来の手法
( scroll + offsetTop )
モダンな手法
( Intersection Observer )
負荷の性質 高い(スクロール中、常に計算を継続) 低い(変化があった瞬間のみ実行)
ユーザー体験 がたつきが発生しやすく、操作感が重い 常に一定の滑らかさを維持できる
コードの保守性 計算式が複雑で、保守コストが高い APIに任せるため、簡潔で読みやすい
ブラウザの挙動 メインスレッドを占有し、リフローを強制 非同期処理により描画を最優先する
主な用途 2010年代以前のレガシーな実装 無限スクロール、画像遅延読み込み、アニメーション発火


ブラウザ・AI対応状況

気になるWebブラウザのサポート状況ですが、以下のURLで確認できます。

Can I use (IntersectionObserver API)
https://caniuse.com/mdn-api_intersectionobserver

主要ブラウザは、PC、スマートフォンともに、すべてサポートされています。
また、ChatGPT AtlasやComet、Perplexity系のブラウザなど、現代のAIブラウザの多くはChromiumベースで構築されています。Intersection Observer はこれらの環境でも標準的にサポートされており、SEOやAIO/GEOの観点からも、構造化された軽いコードはAIエージェントによる正しいコンテンツ理解を助けます。

Intersection Observer API を使った実装


ここからは、Intersection Observer APIを使った実装を、よくあるアニメーションのサンプルでご紹介します。

実装サンプル:下からふわっと浮き上がる演出

まずは、HTML。
シンプルなセクションの構造です。

HTML

<section id="mv">
  <h1>JavaScript<br>Element Fadein on Scroll</h1>
</section>

<section>
  <div class="section-conts ef">
    <h2>HEADLINE 01</h2>
    <p>スクロールすると、この要素が下からスッと浮き上がります。</p>
  </div>
</section>

<section>
  <div class="section-conts ef">
    <h2>HEADLINE 02</h2>
    <p>従来のscrollイベントとは異なり、非常に滑らかです。</p>
  </div>
</section>

<section>
  <div class="section-conts ef">
    <h2>HEADLINE 03</h2>
    <p>一度表示された要素は監視を解除するように設定しています。</p>
  </div>
</section>


メインビジュアルとなる最初のセクションから、複数のセクションを用意しました。
スクロールしてみていくセクションには、アニメーションを適用したい要素に共通クラス ef を付与しておきます。

続いてCSS。
HTML構造に合わせて、ベースとなるスタイルを適用させていきます。
(ここでのCSSのサンプルコードは、ネスト構造で書いていきます)

CSS

/* ベースのスタイル */
section {
  width: 100%;
  height: 100vh;
  background: #e7e7e7;
  padding: 3.75rem 0.625rem;
  &:nth-child(even) {
    background: #d3d3d3;
  }
}

#mv {
  display: flex;
  justify-content: center;
  align-items: center;
  h1 {
    font-size: 3rem;
    text-align: center;
    line-height: 1.8;
  }
}

.section-conts {
  max-width: 1000px;
  margin: auto;
  h2 {
    margin: 0 0 1.25rem;
    text-align: center;
  }
}

/* アニメーション用 */
.ef {
  opacity: 0;
  transform: translateY(30px);
  transition: opacity 1s, transform 1s;
  will-change: opacity, transform;
  &.is-show { /* 交差時にJSで付与されるクラス */
    opacity: 1;
    transform: translateY(0);
  }
}


アニメーションに関わる部分は .ef のスタイルになります。
opacity: 0 と transform: translateY(30px) では、初期状態として要素を透明にし、30px下にずらしておきます。
transition で、透明度と位置の変化を1秒かけて滑らかに動かします。
will-changeでは、ブラウザに描画最適化(GPU加速)を促し、アニメーションを滑らかにします。
&.is-showは、交差時にJSで付与されるクラスに対して不透明にし、元の位置(0)へ戻します。

最後にJavaScript。
Intersection Observer APIを使い、ターゲット要素が画面内(表示領域)に入った瞬間を検知して処理を実行します。
(細かくコメントも入れておきました)

JavaScript

const scrollFade = () => {
  // アニメーションの対象となる要素(.efクラス)をすべて取得
  const fadeElems = document.querySelectorAll(".ef");

  // 監視の挙動を決めるオプション設定
  const options = {
    root: null, // ビューポート(画面全体)を基準にする
    rootMargin: "0px 0px -20% 0px", // 画面下部から20%入った位置で発火させる
    threshold: 0 // 要素が1pxでも入ったら判定を開始
  };

  // 交差した(画面に入った)時に実行する処理
  const callback = (entries, observer) => {
    entries.forEach((entry) => {
      // 要素が画面内に入ったか判定
      if (entry.isIntersecting) {
        // 表示用のクラスを付与
        entry.target.classList.add("is-show");
        // 一度表示されたら監視を解除(リソースの節約)
        observer.unobserve(entry.target);
      }
    });
  };

  // 監視者(Observer)を初期化
  const observer = new IntersectionObserver(callback, options);

  // 各要素の監視を開始
  fadeElems.forEach((el) => {
    observer.observe(el);
  });
};

// 関数を実行
scrollFade();


まず、const scrollFade = () => { … } として、一連のスクロール処理を scrollFade という名前の関数として定義します。こうすることで、コードの再利用性や見通しを良くしています。

関数の中では、まず fadeElems に querySelectorAll で取得した「監視対象となる要素の一覧」を格納します。これがアニメーションを適用するターゲットのリストとなります。

次に、監視のルールを定める options を設定します。ここでは root を null にしてブラウザの画面全体を判定基準とし、rootMargin を -20% に設定することで、要素が画面の下端から20%ほど内側に入った瞬間に反応するように調整しています。また、threshold を 0 とすることで、要素の端が判定ラインに少しでも触れた瞬間に実行されるようになります。

実際の動きを決めるのが callback という命令セットです。ここでは、監視している要素が画面内に入った(isIntersecting が true になった)際に、CSSのクラスを付与してアニメーションを開始させる処理を記述しています。同時に、一度表示された要素については unobserve を実行して監視を解除するようにします。これにより、ページをスクロールし続けても無駄なCPU消費が発生せず、高いパフォーマンスを維持できます。

これらすべての設定と命令を new IntersectionObserver に渡して observer という監視者のインスタンスを生成し、最後に fadeElems の各要素に対して監視を開始させます。

そして、コードの最後に scrollFade(); を記述することで、ページ読み込み時にこの関数を即座に実行させています。

以下、実装の動作になります。
(動画:1分21秒)

応用:フェードインのみにする


ちなみに、要素を動かすと少しデザインが崩れるということもあるかと思います。
動きをなくし、不透明度の変化だけの処理では、CSSとJavaScriptの以下の部分を調整するだけでOKです。

CSS(フェード部分の調整)

/* アニメーション用 */
.ef {
  opacity: 0;
  transition: opacity 1s;
  will-change: opacity;
  &.is-show {
    opacity: 1;
  }
}

元のコードから transform(移動)の記述を削除し、transition と will-change を opacity のみに絞り込んでいます。これにより、その場でじわっと現れる軽快なエフェクトになります。

JavaScript(オプション部分の調整

const options = {
  root: null,
  rootMargin: "0px 0px -10% 0px", // フェードのみなら10%程度が自然
  threshold: 0
};

フェードのみの場合は、画面の端(-10%)に入った瞬間に反応し始めるのが自然です。そのため、rootMargin の値を少し浅めに微調整します。

基本、アニメーションの設定はこのCSS箇所だけで、透過度や移動距離などは自由に調整できます。JavaScript側を触る必要がないため、デザインの微調整もスムーズです。

まとめ:これからのWeb体験を支える技術


これまでのJavaScriptによるスクロール処理は、計算という力技で制御してきましたが、これからはブラウザの機能を賢く使う監視(Intersection Observer)の時代です。

この手法を取り入れることで、デバイスへの負荷を最小限に抑え、どんな環境でもカクつきのない滑らかな動きを実現できます。それは結果として、ページ速度の向上やSEO・AIOへの好影響をもたらし、ユーザーとAIの両方にとって「質の高いサイト」と評価されることにつながります。

ユーザーにとっても、開発者にとっても、そしてAIにとっても優しいモダンなスクロール処理を、ぜひ今日から取り入れてみてください。