08. スクロール演出とIntersection Observer

この章では、スクロールに応じた表示演出を実装します。

Web制作では、セクション見出し、画像、カード、ボタンなどを、画面に入ったタイミングでふわっと表示する演出がよく使われます。

このような処理はscrollイベントでも作れますが、毎回スクロール量を計算するとコードが複雑になりやすく、パフォーマンス面でも注意が必要です。

そこで使うのが、Intersection Observerです。

CSSアニメーションとJavaScriptアニメーション

スクロール演出には、大きく分けて2つの役割があります。

役割担当
いつ表示するかJavaScript
どう表示するかCSS

たとえば、要素が画面に入ったらis-visibleクラスを付けるところまでをJavaScriptで行います。

JavaScriptの役割
target.classList.add("is-visible");

実際のフェードインや移動はCSSで表現します。

CSSの役割
.fade-in {
    opacity: 0;
    transform: translateY(24px);
    transition:
        opacity 0.5s ease,
        transform 0.5s ease;
}

.fade-in.is-visible {
    opacity: 1;
    transform: translateY(0);
}

Intersection Observerとは

Intersection Observerは、ある要素が画面内に入ったかどうかを監視するブラウザ標準のAPIです。

スクロールイベントのように毎回スクロール量を自分で計算しなくても、要素が見えたタイミングで処理できます。

基本形
const target = document.querySelector(".js-fade-in");

const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            entry.target.classList.add("is-visible");
        }
    });
});

if (target) {
    observer.observe(target);
}

entry.isIntersectingtrueなら、対象要素が監視範囲に入ったという意味です。

複数要素を監視する

実務では、フェードインさせたい要素が複数あることが多いです。

HTML
<section class="fade-in js-fade-in">セクション1</section>
<section class="fade-in js-fade-in">セクション2</section>
<section class="fade-in js-fade-in">セクション3</section>
複数要素を監視する
const fadeInTargets = document.querySelectorAll(".js-fade-in");

const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (entry.isIntersecting) {
            entry.target.classList.add("is-visible");
        }
    });
});

fadeInTargets.forEach((target) => {
    observer.observe(target);
});

querySelectorAllで複数要素を取得し、forEachで1つずつobserveします。

一度だけ実行する

フェードイン演出では、1回表示したらその後は監視をやめたいことが多いです。

その場合は、表示後にunobserveします。

一度だけ実行する
const fadeInTargets = document.querySelectorAll(".js-fade-in");

const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (!entry.isIntersecting) {
            return;
        }

        entry.target.classList.add("is-visible");
        observer.unobserve(entry.target);
    });
});

fadeInTargets.forEach((target) => {
    observer.observe(target);
});

この形は、スクロールフェードインでとてもよく使います。

何度も実行する

画面に入ったら表示し、画面外へ出たら戻したい場合もあります。

その場合は、isIntersectingに応じてクラスを付け外しします。

何度も切り替える
const targets = document.querySelectorAll(".js-observe");

const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        entry.target.classList.toggle("is-visible", entry.isIntersecting);
    });
});

targets.forEach((target) => {
    observer.observe(target);
});

ただし、スクロールのたびに何度もアニメーションが起きると、落ち着かない印象になることがあります。

Webサイトの読みやすさを優先して、使いどころを選びましょう。

rootMargin

rootMarginは、監視範囲を広げたり狭めたりする設定です。

たとえば、要素が画面の少し手前まで来たら表示したい場合があります。

rootMargin
const observer = new IntersectionObserver(
    (entries) => {
        entries.forEach((entry) => {
            if (entry.isIntersecting) {
                entry.target.classList.add("is-visible");
                observer.unobserve(entry.target);
            }
        });
    },
    {
        rootMargin: "0px 0px -20% 0px",
    }
);

rootMargin: "0px 0px -20% 0px"は、画面下側の判定ラインを少し上にずらすようなイメージです。

要素が画面にかなり入ってから発火させたい時に使えます。

threshold

thresholdは、対象要素がどれくらい見えたら発火するかを指定します。

threshold
const observer = new IntersectionObserver(callback, {
    threshold: 0.5,
});

0.5なら、要素が50%見えた時に反応します。

意味
0少しでも交差したら反応
0.5半分見えたら反応
1全部見えたら反応

最初は、rootMarginで発火タイミングを調整することが多いです。

フェードイン実装

ここからは、実務でよく使うフェードインの基本形を作ります。

HTML
<section class="fade-in js-fade-in">
    <h2>サービス</h2>
    <p>サービス紹介の本文が入ります。</p>
</section>
CSS
.fade-in {
    opacity: 0;
    transform: translateY(24px);
    transition:
        opacity 0.5s ease,
        transform 0.5s ease;
}

.fade-in.is-visible {
    opacity: 1;
    transform: translateY(0);
}
JavaScript
const fadeInTargets = document.querySelectorAll(".js-fade-in");

const fadeInObserver = new IntersectionObserver(
    (entries) => {
        entries.forEach((entry) => {
            if (!entry.isIntersecting) {
                return;
            }

            entry.target.classList.add("is-visible");
            fadeInObserver.unobserve(entry.target);
        });
    },
    {
        rootMargin: "0px 0px -20% 0px",
    }
);

fadeInTargets.forEach((target) => {
    fadeInObserver.observe(target);
});

これで、対象要素が画面に入ったタイミングでis-visibleが付き、CSSのtransitionで表示されます。

順番表示

カードを順番に表示したい場合は、CSS変数やtransition-delayを使う方法があります。

HTML
<div class="card-list">
    <article class="card js-stagger-item">カード1</article>
    <article class="card js-stagger-item">カード2</article>
    <article class="card js-stagger-item">カード3</article>
</div>
CSS
.card {
    opacity: 0;
    transform: translateY(24px);
    transition:
        opacity 0.5s ease,
        transform 0.5s ease;
    transition-delay: var(--delay, 0s);
}

.card.is-visible {
    opacity: 1;
    transform: translateY(0);
}
JavaScript
const staggerItems = document.querySelectorAll(".js-stagger-item");

staggerItems.forEach((item, index) => {
    item.style.setProperty("--delay", `${index * 0.08}s`);
});

const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (!entry.isIntersecting) {
            return;
        }

        entry.target.classList.add("is-visible");
        observer.unobserve(entry.target);
    });
});

staggerItems.forEach((item) => {
    observer.observe(item);
});

この例では、要素の順番に応じて--delayを設定しています。

セクションごとに順番表示する

ページ全体のカードに一気に連番を付けると、下のセクションほど遅延が大きくなることがあります。

実務では、セクションごとに順番をリセットする方が自然です。

HTML
<section class="js-stagger-group">
    <article class="js-stagger-item">カード1</article>
    <article class="js-stagger-item">カード2</article>
    <article class="js-stagger-item">カード3</article>
</section>
グループごとにdelayを設定する
const groups = document.querySelectorAll(".js-stagger-group");

groups.forEach((group) => {
    const items = group.querySelectorAll(".js-stagger-item");

    items.forEach((item, index) => {
        item.style.setProperty("--delay", `${index * 0.08}s`);
    });
});

「ページ全体」ではなく「そのセクション内」で処理すると、後からセクションが増えても管理しやすくなります。

数字カウントアップの入口

数字が画面に入った時にカウントアップする演出もよくあります。

ここでは簡易的な入口だけ見ておきます。

HTML
<p><span class="js-count-up" data-count="120">0</span>件</p>
簡易カウントアップ
const countTargets = document.querySelectorAll(".js-count-up");

const observer = new IntersectionObserver((entries) => {
    entries.forEach((entry) => {
        if (!entry.isIntersecting) {
            return;
        }

        const target = entry.target;
        const endValue = Number(target.dataset.count);
        let currentValue = 0;

        const timerId = setInterval(() => {
            currentValue += 1;
            target.textContent = String(currentValue);

            if (currentValue >= endValue) {
                clearInterval(timerId);
            }
        }, 20);

        observer.unobserve(target);
    });
});

countTargets.forEach((target) => {
    observer.observe(target);
});

これは学習用の簡易例です。

実務では、桁数が大きい場合、時間を固定したい場合、小数点を扱う場合などで調整が必要です。

prefers-reduced-motion

ユーザーの環境設定によっては、アニメーションを減らしたい人もいます。

CSSでは、prefers-reduced-motionを使ってアニメーションを控えめにできます。

reduced motion対応
@media (prefers-reduced-motion: reduce) {
    .fade-in {
        opacity: 1;
        transform: none;
        transition: none;
    }
}

JavaScript側でも、必要に応じて演出をスキップできます。

JavaScriptで判定する
const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

if (prefersReducedMotion) {
    fadeInTargets.forEach((target) => {
        target.classList.add("is-visible");
    });
}

スクロール演出の注意点

スクロール演出は便利ですが、やりすぎると読みにくくなります。

  • すべての要素を遅れて表示しすぎない
  • スマホで重くならないか確認する
  • 表示前の要素がスペースを持ち、レイアウトが大きくズレないようにする
  • opacitytransform中心にする
  • 文字が読めるまでの時間を長くしすぎない
  • prefers-reduced-motionを考慮する

特に、最初は「動いていると良く見える」と感じやすいです。

しかし、Webサイトの目的は情報を伝えることです。演出が情報の邪魔をしないか、必ず確認しましょう。

よくある失敗

Intersection Observerでよくある失敗を整理しておきます。

  • observeする前に対象要素が存在するか確認していない
  • unobserveを忘れて、不要な監視が残る
  • rootMarginの値を大きくしすぎて、表示タイミングが早すぎる
  • threshold: 1にして、要素が大きい時に発火しない
  • 非表示時にdisplay: noneにしていて、監視対象として扱いづらくなる
  • アニメーション前の状態で要素の高さが変わり、レイアウトがずれる
  • スマホで演出が多すぎて重くなる

最初は、フェードインの基本形だけでも十分です。

慣れてきたら、順番表示、カウントアップ、ページトップボタンとの組み合わせなどに進みましょう。

この章のまとめ

この章では、Intersection Observerを使ったスクロール演出を学びました。

  • JavaScriptは表示タイミングを決め、見た目の動きはCSSに任せる
  • Intersection Observerを使うと、要素が画面に入ったタイミングを検知できる
  • 一度だけ表示する演出では、表示後にunobserveする
  • rootMarginで発火タイミングを調整できる
  • thresholdでどれくらい見えたら反応するか指定できる
  • 順番表示では、遅延を大きくしすぎない
  • スクロール演出は、読みやすさとパフォーマンスを優先する

次の章では、より高度なアニメーションを作るためにGSAPを学びます。