JavaScriptでスクロールアニメーションを実装する方法|IntersectionObserverで要素をフェードイン


はじめに

前回の記事ではJavaScriptで複数の画像を順番に表示する方法としてスライドショー・ギャラリー・ライトボックスの実装を解説しました。今回はJavaScriptでスクロールアニメーションを実装する方法を解説します。「スクロールすると要素がふわっと現れるあの演出を作りたい」という方は多いのではないでしょうか。コーポレートサイトやLPでは定番の演出で、副業案件でも頻繁に求められます。IntersectionObserver を使うとシンプルなコードで実装できます。


1. スクロールアニメーションの仕組み

1.1 昔と今の実装方法の違い

昔の実装方法(scrollイベント):

window.addEventListener( 'scroll', () => {
    // スクロールのたびに全要素をチェックする
    // → 1秒間に何十回も発火する
    // → getBoundingClientRect() を毎回呼ぶ
    // → パフォーマンスが悪くなる
} );

問題点:
→ スクロールするたびに重い処理が走る
→ スマートフォンでカクつく
→ コードが複雑になる

今の実装方法(IntersectionObserver):

→ 要素が画面に入ったタイミングだけ処理する
→ ブラウザが最適なタイミングで処理してくれる
→ パフォーマンスが良い
→ コードがシンプル
→ モダンブラウザはすべて対応済み

1.2 IntersectionObserverとは

javascript

/*
  IntersectionObserver:
  監視している要素が
  画面(または指定した領域)に
  入ったり出たりしたときに
  コールバック関数を呼んでくれるAPI。

  基本的な使い方:
*/

// ① オブザーバーを作る
const observer = new IntersectionObserver( ( entries ) => {
    // entries:監視中の要素の状態の配列
    entries.forEach( ( entry ) => {
        // entry.isIntersecting:画面内に入ったか
        if ( entry.isIntersecting ) {
            console.log( '画面に入った!', entry.target );
        }
    } );
} );

// ② 監視する要素を登録する
const target = document.querySelector( '.target' );
observer.observe( target );

// ③ 監視をやめるとき
observer.unobserve( target );
observer.disconnect(); // すべての監視をやめる

2. シンプルなフェードインアニメーション

2.1 HTMLの準備

<!--
    アニメーションさせたい要素に
    クラスを付けておく
-->

<section class="section">
    <h2 class="fade-in">見出しテキスト</h2>
    <p class="fade-in">本文テキストが入ります。</p>
    <div class="fade-in">
        <img src="image.jpg" alt="画像">
    </div>
</section>

<section class="section">
    <div class="fade-in">コンテンツ1</div>
    <div class="fade-in">コンテンツ2</div>
    <div class="fade-in">コンテンツ3</div>
</section>

2.2 CSSの準備

/*
  アニメーション前の状態(非表示)
  → 最初は透明で少し下にある
*/
.fade-in {
    opacity:    0;
    transform:  translateY( 30px );
    transition: opacity    0.6s ease,
                transform  0.6s ease;
}

/*
  アニメーション後の状態(表示)
  → JavaScriptでこのクラスを付ける
*/
.fade-in.is-visible {
    opacity:   1;
    transform: translateY( 0 );
}

/*
  prefers-reduced-motion:
  「動きを減らす」設定をしているユーザーへの配慮
  → アニメーションを無効にする
*/
@media ( prefers-reduced-motion: reduce ) {
    .fade-in {
        opacity:    1;
        transform:  none;
        transition: none;
    }
}

2.3 JavaScriptの実装

// すべての .fade-in 要素を取得する
const fadeElements = document.querySelectorAll( '.fade-in' );

// IntersectionObserver を作る
const observer = new IntersectionObserver(
    ( entries ) => {
        entries.forEach( ( entry ) => {
            // 画面に入ったら is-visible クラスを付ける
            if ( entry.isIntersecting ) {
                entry.target.classList.add( 'is-visible' );

                // 一度表示したら監視をやめる
                // → 再びスクロールアップしても消えない
                observer.unobserve( entry.target );
            }
        } );
    },
    {
        // オプション
        threshold:   0.1,    // 要素の10%が見えたら発火
        rootMargin: '0px 0px -50px 0px', // 下から50px手前で発火
    }
);

// すべての要素を監視登録する
fadeElements.forEach( ( el ) => observer.observe( el ) );

💡 rootMargin の使い方: '0px 0px -50px 0px' は「画面の下端より50px上に入ったとき」に発火します。マイナスの値を指定することで、要素が画面に入りきってから発火させることができます。


3. アニメーションのバリエーション

3.1 方向別のフェードイン

css

/* 下からフェードイン(デフォルト) */
.fade-in-up {
    opacity:   0;
    transform: translateY( 40px );
    transition: opacity   0.6s ease,
                transform 0.6s ease;
}

/* 左からフェードイン */
.fade-in-left {
    opacity:   0;
    transform: translateX( -40px );
    transition: opacity   0.6s ease,
                transform 0.6s ease;
}

/* 右からフェードイン */
.fade-in-right {
    opacity:   0;
    transform: translateX( 40px );
    transition: opacity   0.6s ease,
                transform 0.6s ease;
}

/* 拡大しながらフェードイン */
.fade-in-zoom {
    opacity:   0;
    transform: scale( 0.9 );
    transition: opacity   0.6s ease,
                transform 0.6s ease;
}

/* 表示状態(共通) */
.fade-in-up.is-visible,
.fade-in-left.is-visible,
.fade-in-right.is-visible,
.fade-in-zoom.is-visible {
    opacity:   1;
    transform: translate( 0 ) scale( 1 );
}

/* アクセシビリティ対応(共通) */
@media ( prefers-reduced-motion: reduce ) {
    .fade-in-up,
    .fade-in-left,
    .fade-in-right,
    .fade-in-zoom {
        opacity:    1;
        transform:  none;
        transition: none;
    }
}
html

<!-- 方向を使い分ける -->
<div class="content-row">
    <div class="fade-in-left">左から現れるコンテンツ</div>
    <div class="fade-in-right">右から現れるコンテンツ</div>
</div>

<div class="card fade-in-up">下から現れるカード</div>
<div class="card fade-in-zoom">拡大しながら現れるカード</div>
javascript

// すべてのアニメーションクラスをまとめて監視する
const animElements = document.querySelectorAll(
    '.fade-in-up, .fade-in-left, .fade-in-right, .fade-in-zoom'
);

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 -40px 0px' }
);

animElements.forEach( ( el ) => observer.observe( el ) );

4. 順番に遅れて表示する(stagger)

4.1 data属性で遅延を設定する

html

<!--
    カードを順番に遅らせて表示する
    data-delay で遅延時間をミリ秒で指定する
-->
<div class="card-grid">
    <div class="card fade-in" data-delay="0">カード1</div>
    <div class="card fade-in" data-delay="100">カード2</div>
    <div class="card fade-in" data-delay="200">カード3</div>
    <div class="card fade-in" data-delay="300">カード4</div>
    <div class="card fade-in" data-delay="400">カード5</div>
    <div class="card fade-in" data-delay="500">カード6</div>
</div>

<!--
    同じ親の中にあるアイテムは
    JavaScriptで自動的に遅延を付けることもできる
-->
<ul class="service-list">
    <li class="fade-in">サービス1</li>
    <li class="fade-in">サービス2</li>
    <li class="fade-in">サービス3</li>
</ul>
javascript

// data-delay を使った遅延アニメーション
const fadeElements = document.querySelectorAll( '.fade-in' );

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

            const el    = entry.target;
            const delay = el.dataset.delay ?? 0;

            // 遅延してからクラスを付ける
            setTimeout( () => {
                el.classList.add( 'is-visible' );
            }, Number( delay ) );

            observer.unobserve( el );
        } );
    },
    { threshold: 0.1, rootMargin: '0px 0px -30px 0px' }
);

fadeElements.forEach( ( el ) => observer.observe( el ) );

// ============================================

// 親要素の子要素に自動で遅延を付ける
function addStaggerDelay( parentSelector, childSelector, step = 100 ) {
    document.querySelectorAll( parentSelector ).forEach( ( parent ) => {
        parent.querySelectorAll( childSelector ).forEach( ( child, i ) => {
            child.dataset.delay = i * step;
        } );
    } );
}

// 使用例
addStaggerDelay( '.card-grid',    '.card',    150 );
addStaggerDelay( '.service-list', '.fade-in', 100 );

4.2 CSSのcustom propertiesを使う方法

html

<!--
    CSSのカスタムプロパティで遅延を渡す方法
    より柔軟に設定できる
-->
<div class="stagger-group">
    <div class="stagger-item" style="--delay: 0ms">アイテム1</div>
    <div class="stagger-item" style="--delay: 100ms">アイテム2</div>
    <div class="stagger-item" style="--delay: 200ms">アイテム3</div>
</div>
css

.stagger-item {
    opacity:          0;
    transform:        translateY( 24px );
    transition:       opacity   0.5s ease var( --delay, 0ms ),
                      transform 0.5s ease var( --delay, 0ms );
    /* → transition-delay に --delay を使う */
}

.stagger-item.is-visible {
    opacity:   1;
    transform: translateY( 0 );
}
javascript

// stagger-item にクラスを付けるだけでよい
const staggerItems = document.querySelectorAll( '.stagger-item' );

const observer = new IntersectionObserver(
    ( entries ) => {
        entries.forEach( ( entry ) => {
            if ( entry.isIntersecting ) {
                entry.target.classList.add( 'is-visible' );
                observer.unobserve( entry.target );
            }
        } );
    },
    { threshold: 0.05 }
);

staggerItems.forEach( ( el ) => observer.observe( el ) );

5. カウントアップアニメーション

5.1 数字が増えていく演出

html

<!-- 実績数字などによく使われる -->
<div class="stats-section">
    <div class="stat fade-in">
        <span class="count-up" data-target="150">0</span>
        <span class="stat-unit">件</span>
        <p class="stat-label">制作実績</p>
    </div>
    <div class="stat fade-in" data-delay="150">
        <span class="count-up" data-target="98">0</span>
        <span class="stat-unit">%</span>
        <p class="stat-label">顧客満足度</p>
    </div>
    <div class="stat fade-in" data-delay="300">
        <span class="count-up" data-target="10">0</span>
        <span class="stat-unit">年</span>
        <p class="stat-label">業界経験</p>
    </div>
</div>
javascript

// カウントアップ関数
function countUp( el, target, duration = 1500 ) {
    const start     = 0;
    const startTime = performance.now();

    function update( currentTime ) {
        // 経過時間の割合(0〜1)
        const elapsed  = currentTime - startTime;
        const progress = Math.min( elapsed / duration, 1 );

        // イージング(徐々に遅くなる)
        const eased = 1 - Math.pow( 1 - progress, 3 );

        // 現在の数値を計算して表示する
        el.textContent = Math.round( start + ( target - start ) * eased );

        // アニメーションが終わっていなければ続ける
        if ( progress < 1 ) {
            requestAnimationFrame( update );
        } else {
            el.textContent = target; // 最終値を確実に表示する
        }
    }

    requestAnimationFrame( update );
}

// IntersectionObserver で画面に入ったらカウントアップ開始
const countEls = document.querySelectorAll( '.count-up' );

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

            const el     = entry.target;
            const target = Number( el.dataset.target );
            const delay  = Number( el.closest( '[data-delay]' )?.dataset.delay ?? 0 );

            setTimeout( () => countUp( el, target ), delay );
            countObserver.unobserve( el );
        } );
    },
    { threshold: 0.5 } // 要素の50%が見えたら開始
);

countEls.forEach( ( el ) => countObserver.observe( el ) );

6. 実案件向けのまとめた実装

6.1 一括管理できるクラスで実装する

javascript

/*
  実案件で使いやすいようにまとめた実装。
  HTMLにクラスとdata属性を付けるだけで動く。
*/

class ScrollAnimation {
    constructor() {
        this.#init();
    }

    #init() {
        // フェードイン系
        this.#observeFade();

        // カウントアップ
        this.#observeCount();
    }

    // フェードインの監視
    #observeFade() {
        const elements = document.querySelectorAll(
            '.fade-in, .fade-in-up, .fade-in-left, .fade-in-right, .fade-in-zoom, .stagger-item'
        );

        if ( elements.length === 0 ) return;

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

                    const el    = entry.target;
                    const delay = Number( el.dataset.delay ?? 0 );

                    setTimeout( () => {
                        el.classList.add( 'is-visible' );
                    }, delay );

                    observer.unobserve( el );
                } );
            },
            {
                threshold:  0.1,
                rootMargin: '0px 0px -40px 0px',
            }
        );

        elements.forEach( ( el ) => observer.observe( el ) );
    }

    // カウントアップの監視
    #observeCount() {
        const elements = document.querySelectorAll( '.count-up' );
        if ( elements.length === 0 ) return;

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

                    const el     = entry.target;
                    const target = Number( el.dataset.target ?? 0 );
                    const delay  = Number(
                        el.closest( '[data-delay]' )?.dataset.delay ?? 0
                    );

                    setTimeout( () => this.#countUp( el, target ), delay );
                    observer.unobserve( el );
                } );
            },
            { threshold: 0.5 }
        );

        elements.forEach( ( el ) => observer.observe( el ) );
    }

    #countUp( el, target, duration = 1500 ) {
        const startTime = performance.now();

        const update = ( currentTime ) => {
            const progress = Math.min( ( currentTime - startTime ) / duration, 1 );
            const eased    = 1 - Math.pow( 1 - progress, 3 );

            el.textContent = Math.round( target * eased );

            if ( progress < 1 ) {
                requestAnimationFrame( update );
            } else {
                el.textContent = target;
            }
        };

        requestAnimationFrame( update );
    }
}

// DOMが読み込まれたら初期化する
document.addEventListener( 'DOMContentLoaded', () => {
    new ScrollAnimation();
} );

6.2 HTMLでの使い方まとめ

<!-- ① シンプルなフェードイン -->
<div class="fade-in">フェードインするコンテンツ</div>

<!-- ② 方向付きフェードイン -->
<div class="fade-in-left">左から現れる</div>
<div class="fade-in-right">右から現れる</div>
<div class="fade-in-zoom">拡大しながら現れる</div>

<!-- ③ 遅延付きフェードイン -->
<div class="fade-in" data-delay="0">最初に現れる</div>
<div class="fade-in" data-delay="150">少し遅れて現れる</div>
<div class="fade-in" data-delay="300">さらに遅れて現れる</div>

<!-- ④ staggerアニメーション(CSSカスタムプロパティ版) -->
<div class="stagger-item" style="--delay: 0ms">アイテム1</div>
<div class="stagger-item" style="--delay: 100ms">アイテム2</div>
<div class="stagger-item" style="--delay: 200ms">アイテム3</div>

<!-- ⑤ カウントアップ -->
<span class="count-up" data-target="150">0</span>

7. よくある問題と対処法

7.1 ページ読み込み直後に発火してしまう

javascript

/*
  問題:
  ページ上部にある要素が
  読み込み直後に is-visible になってしまう

  原因:
  ページ読み込み時点で
  すでに画面内に要素があるため

  対処法:
  → そのまま発火させてOK(正しい動作)
  → 最初から見える要素にアニメーションを
    付けないようにHTMLを設計する
  → ファーストビューの要素には
    fade-in クラスを付けない
*/

// ファーストビューには付けない
// ❌
<header class="hero fade-in">...</header>

// ✅ ファーストビュー以降から付ける
<section class="about">
    <h2 class="fade-in">About</h2>
    <p class="fade-in" data-delay="150">テキスト</p>
</section>

7.2 スマートフォンで重くなる

javascript

/*
  対処法:
  監視する要素が多い場合は
  threshold を下げてチェック回数を減らす

  または、アニメーション対象を絞る
*/

// ❌ すべての要素を監視すると重くなることがある
document.querySelectorAll( '*' ).forEach( ... );

// ✅ 必要な要素だけ監視する
document.querySelectorAll( '.fade-in, .count-up' ).forEach( ... );

// ✅ threshold を 0 にすると最も軽い
const observer = new IntersectionObserver( callback, {
    threshold: 0, // 1pxでも見えたら発火
} );

8. 実践チェックリスト

✓ opacity: 0 と transform: translateY() でアニメーション前の状態をCSSで定義しているか

✓ IntersectionObserver で要素が画面に入ったときだけ処理しているか

✓ 一度表示したら observer.unobserve() で監視を解除しているか

✓ data-delay 属性で要素ごとに遅延時間を設定して順番に表示しているか

✓ @media (prefers-reduced-motion: reduce) でアニメーションを無効にしているか

✓ ファーストビューの要素には fade-in クラスを付けていないか

✓ rootMargin: '0px 0px -40px 0px' で要素が見え始める少し手前で発火させているか

✓ スマートフォンでカクつかないか実機で確認しているか


まとめ

今回はJavaScriptでスクロールアニメーションを実装する方法を解説しました。ポイントをまとめると:

  • IntersectionObserver を使うとスクロールイベントより軽くシンプルにスクロールアニメーションが実装できる
  • CSSで opacity: 0 と transform で非表示状態を定義して・JavaScriptで is-visible クラスを付けるだけで動く
  • threshold で要素の何%が見えたら発火するかを・rootMargin で発火タイミングの微調整ができる
  • data-delay 属性と setTimeout の組み合わせでカードなどを順番に遅らせて表示できる
  • CSSのカスタムプロパティ(--delay)を transition-delay に使う方法もシンプルで使いやすい
  • カウントアップアニメーションは requestAnimationFrame と IntersectionObserver の組み合わせで実装できる
  • @media (prefers-reduced-motion: reduce) でアニメーションを無効にしてアクセシビリティに配慮する
  • ファーストビューの要素にはアニメーションクラスを付けない・監視対象は必要な要素だけに絞る

次の記事では、CSSでハンバーガーメニューを実装する方法を解説します。スマートフォン対応のナビゲーションをJavaScriptで開閉・アニメーション・オーバーレイの実装まで丁寧にお届けします。お楽しみに!

コメント

タイトルとURLをコピーしました