こんにちは、かつコーチです。
前回は、スライダー・カルーセルの実装を解説しました。
今回からは「実践UI実装」シリーズとして、よく使われるUIパーツを一つずつ作っていきます。
第1弾は、スクロールすると要素がふわっと表示される、あの定番演出です。
「scrollイベントで頑張って実装しようとしたら、カクカクになった」という経験がある人も多いのではないでしょうか。
今日はその悩みを解決してくれるIntersection ObserverというAPIを使って、なめらかなスクロールアニメーションを実装していきます。
Intersection Observerとは?
「要素が画面に入ったか」を監視するAPI
Intersection Observerは、指定した要素が画面(またはある要素)の中に入ったかどうかを、ブラウザが自動的に監視してくれるAPIです。
「Intersection」は「交差」という意味で、監視対象の要素と画面の表示領域が「交差した瞬間」を教えてくれる、とイメージすると分かりやすいです。
このAPIが登場する前は、スクロール量をscrollイベントで取得し、要素の座標をgetBoundingClientRect()で計算して、画面内に入ったかどうかを毎回自分で判定する必要がありました。
なぜscrollイベントの監視だとカクつくのか
scrollイベントは、スクロール中に1秒間に何十回も発火します。
そのたびに要素の座標を計算する処理を書いていると、ブラウザの描画処理を圧迫してカクつきの原因になります。
私が最初に「scrollイベント+getBoundingClientRect()」でスクロールアニメーションを作ったときも、スマホで確認したら明らかにガクガクしていて、これは根本的にやり方を変えないといけないと痛感した経験があります。
Intersection Observerは、ブラウザ側で「交差判定」の処理を最適化してくれているため、scrollイベントを自前で監視するより圧倒的に軽い処理で同じことが実現できます。
基本の書き方
手順1:監視対象の要素をHTMLに用意する
まずは、スクロールで表示させたい要素をHTMLに用意します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Intersection Observerサンプル</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="spacer">上にスクロールしてください</div>
<section class="fade-in">セクション1:ふわっと表示されます</section>
<section class="fade-in">セクション2:ふわっと表示されます</section>
<section class="fade-in">セクション3:ふわっと表示されます</section>
<script src="script.js"></script>
</body>
</html>
手順2:CSSで「初期状態」と「表示済み状態」を用意する
アニメーションの土台となるCSSを用意します。
ポイントは、「まだ画面に入っていない状態」と「画面に入った後の状態」の2つのクラスを用意することです。
.spacer {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
color: #888;
}
.fade-in {
height: 40vh;
margin: 40px 0;
display: flex;
align-items: center;
justify-content: center;
background: #eef3ff;
font-size: 24px;
/* 初期状態:透明&少し下にずらしておく */
opacity: 0;
transform: translateY(30px);
transition: opacity 0.6s ease, transform 0.6s ease;
}
/* JavaScriptで付与するクラス:画面に入ったらこの見た目になる */
.fade-in.is-visible {
opacity: 1;
transform: translateY(0);
}
手順3:JavaScriptでIntersectionObserverを作る
ここからが本題です。
// 監視したい要素をすべて取得する
const targets = document.querySelectorAll('.fade-in');
// オブザーバー(監視役)を作成する
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
// 画面に入ったかどうかは entry.isIntersecting で判定する
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
}, {
threshold: 0.2, // 要素が20%見えたタイミングで発火させる
});
// 対象要素すべてに監視を開始させる
targets.forEach((target) => {
observer.observe(target);
});
new IntersectionObserver(コールバック関数, オプション)という形で監視役を作り、observe()に渡した要素それぞれを監視します。
要素が画面に入ったり出たりするたびにコールバック関数が呼ばれ、引数のentriesには「監視している要素の状態一覧」が配列で渡ってきます。
entry.isIntersectingがtrueのとき、その要素は今まさに画面と交差している(=表示されている)状態です。
オプションでタイミングを細かく調整する
threshold:どのくらい見えたら発火させるか
thresholdは、要素がどのくらいの割合見えたときにコールバックを発火させるかを指定するオプションです。
| 指定値 | 意味 |
|---|---|
0(デフォルト) | 要素が1pxでも見えたら発火 |
0.5 | 要素が50%見えたら発火 |
1.0 | 要素が100%(全体)見えたら発火 |
[0, 0.5, 1.0] | 複数指定すると、それぞれのタイミングで発火 |
「要素の上端がちょっと見えただけで動き出すのは早すぎる」と感じる場合は、thresholdを0.2〜0.3程度にすると、ちょうどよいタイミングで演出が始まります。
rootMargin:判定領域を前後にずらす
rootMarginは、監視領域(通常はビューポート)を仮想的に広げたり狭めたりするオプションです。
const observer = new IntersectionObserver(callback, {
threshold: 0.1,
rootMargin: '0px 0px -100px 0px', // 下方向の判定を100px手前にする
});
CSSのmarginと同じ書式(上・右・下・左)で指定し、マイナス値を指定すると判定領域が狭くなります。
上の例では、画面の下端よりも100px手前に要素が入った時点で発火するようになるため、「画面の一番下ギリギリでアニメーションが始まって忙しない」という違和感を解消できます。
つまずきポイント:一度表示した要素がスクロールで戻ると消えてしまう
私が実際につまずいたのが、この「表示済みの要素が、上にスクロールし直すと再び透明に戻ってしまう」という現象です。
❌ Before:unobserveを呼ばず、常に監視し続けてしまう
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
} else {
// 画面外に出たらクラスを消してしまっている
entry.target.classList.remove('is-visible');
}
});
});
このコードだと、一度表示された要素も、画面外に出るたびにis-visibleが外れてしまいます。
ユーザーが上下にスクロールを繰り返すたびに要素がチカチカと表示・非表示を繰り返し、演出というより「ノイズ」になってしまいました。
✅ After:一度表示したら監視を終了する(unobserve)
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
// 一度表示させたら、それ以降は監視しない
observer.unobserve(entry.target);
}
});
}, { threshold: 0.2 });
observer.unobserve(要素)を呼ぶことで、その要素の監視だけを個別に終了できます。
「一度出たら出たまま」という一般的なスクロールアニメーションの挙動にしたい場合は、isIntersectingがtrueになった時点でunobserveするのが定石です。
逆に「画面内にいる間だけ光らせる」といったUIを作りたい場合は、あえてunobserveせずに出入りのたびにクラスを切り替える設計もありです。
応用:複数の演出パターンを1つのオブザーバーでまとめる
data属性でアニメーションの種類を切り替える
実際のサイトでは「下からフェードイン」「左からスライドイン」など、複数の演出パターンを使い分けたい場面が多いです。
data-animation属性を使って、1つのオブザーバーでまとめて管理する方法を紹介します。
<section class="reveal" data-animation="fade-up">下からフェードイン</section>
<section class="reveal" data-animation="slide-left">左からスライドイン</section>
.reveal {
opacity: 0;
transition: opacity 0.6s ease, transform 0.6s ease;
}
.reveal[data-animation="fade-up"] {
transform: translateY(30px);
}
.reveal[data-animation="slide-left"] {
transform: translateX(-40px);
}
.reveal.is-visible {
opacity: 1;
transform: translate(0, 0);
}
const reveals = document.querySelectorAll('.reveal');
const revealObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
revealObserver.unobserve(entry.target);
}
});
}, { threshold: 0.2, rootMargin: '0px 0px -50px 0px' });
reveals.forEach((el) => revealObserver.observe(el));
data-animationの値ごとにCSSの初期状態だけを変えておけば、JavaScript側は「監視してis-visibleを付けるだけ」というシンプルな処理のまま、複数の演出パターンに対応できます。
遅延(ディレイ)をつけて順番に表示する
複数の要素を少しずつ時間差で表示させたい場合は、transition-delayをCSS変数で調整する方法が便利です。
<div class="cards">
<div class="reveal" style="--delay: 0s">カード1</div>
<div class="reveal" style="--delay: 0.15s">カード2</div>
<div class="reveal" style="--delay: 0.3s">カード3</div>
</div>
.reveal {
transition-delay: var(--delay, 0s);
}
JavaScript側の実装は変えずに、HTML側でインラインスタイルとして--delayを指定するだけで、要素ごとに表示タイミングをずらせます。
まとめ
この記事のポイント
- Intersection Observerは、要素が画面に入ったかどうかをブラウザが最適化して監視してくれるAPIである
scrollイベントを自前で監視するより軽量で、カクつきの少ないスクロールアニメーションを実装できるthresholdで発火のタイミング、rootMarginで判定領域を細かく調整できる- 一度表示した要素は
unobserve()で監視を終了しないと、スクロールのたびにチカチカする原因になる data属性とCSS変数を組み合わせると、演出パターンや遅延を柔軟に管理できる
次に読むべき記事
次回は、フォームのリアルタイムバリデーションの実装を解説します。
→ 次の記事:フォームのリアルタイムバリデーションを実装する
