はじめに
前回の記事では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で開閉・アニメーション・オーバーレイの実装まで丁寧にお届けします。お楽しみに!
コメント