JavaScriptで要素が画面に入ったらアニメーションする方法(IntersectionObserver)
スクロールして要素が画面に入ったらふわっと表示する、数字を動かす、画像を遅延読み込みする。こうした処理で昔よく使われていたのがscrollイベントとgetBoundingClientRect()の組み合わせです。
ただ、スクロールイベントで毎回位置計算をすると、実装が複雑になり、パフォーマンスにも気を使います。IntersectionObserverを使えば、要素とviewportの交差状態をブラウザに監視してもらい、必要なタイミングでコールバックを受け取れます。
IntersectionObserverとは
IntersectionObserverは、対象要素がviewportまたは指定した親要素と交差したかを非同期に監視するWeb APIです。MDNでは、要素が祖先要素またはviewportと交差する変化を非同期に観察する方法として説明されています。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
}
});
});
observer.observe(document.querySelector('.target'));
スクロール量を自分で計算するのではなく、entry.isIntersectingやentry.intersectionRatioを見て状態を判断します。
基本構文
const options = {
root: null,
rootMargin: '0px 0px -10% 0px',
threshold: 0.2,
};
const observer = new IntersectionObserver(callback, options);
root:交差判定の基準。nullならviewport。rootMargin:判定領域を広げたり狭めたりする余白。早めに発火したい時に便利。threshold:どれくらい見えたら発火するか。0なら少し触れた時、1なら全体が見えた時。
実装例1:画面に入ったら一度だけ表示する
<section class="reveal">表示したいコンテンツ</section>
<section class="reveal">次のコンテンツ</section>
.reveal {
opacity: 0;
transform: translateY(32px);
transition: opacity .6s ease, transform .6s ease;
}
.reveal.is-visible {
opacity: 1;
transform: translateY(0);
}
const targets = document.querySelectorAll('.reveal');
const observer = new IntersectionObserver((entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
});
}, {
threshold: 0.2,
});
targets.forEach((target) => observer.observe(target));
unobserve()を呼ぶと、その要素の監視を止められます。一度だけ表示すればよいスクロール演出では、監視対象を減らしておくと無駄がありません。
実装例2:少し早めに発火させる
要素が完全に見えてから動き始めると、体感として少し遅く感じる場合があります。rootMarginを使うと、判定領域を調整できます。
const observer = new IntersectionObserver(callback, {
root: null,
rootMargin: '0px 0px -20% 0px',
threshold: 0,
});
下側にマイナスのmarginを入れると、viewport下端より少し内側へ入ったタイミングで発火します。ファーストビュー直下の演出では、自然に見える値を実機で調整してください。
実装例3:繰り返し表示・非表示を切り替える
一度だけではなく、画面外へ出たら戻したい場合はunobserve()せず、classList.toggle()を使います。
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
entry.target.classList.toggle('is-visible', entry.isIntersecting);
});
}, {
threshold: 0.35,
});
実装例4:親スクロール領域を監視する
モーダルや横長のパネルなど、windowではなく内側の要素がスクロールするUIでは、rootへスクロールコンテナを指定します。
const scrollArea = document.querySelector('.scroll-area');
const items = document.querySelectorAll('.scroll-area .item');
const observer = new IntersectionObserver(callback, {
root: scrollArea,
threshold: 0.5,
});
items.forEach((item) => observer.observe(item));
CSS
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #111;
color: #fff;
font-family: system-ui, sans-serif;
}
.io-page {
width: min(100% - 32px, 760px);
margin: 0 auto;
}
.intro {
min-height: 100vh;
display: grid;
place-content: center;
}
.intro p {
color: #ff2f63;
font-weight: 800;
letter-spacing: .16em;
text-transform: uppercase;
}
.intro h1 {
margin: 0;
font-size: clamp(40px, 8vw, 88px);
line-height: 1;
}
.reveal-card {
margin: 0 0 40vh;
padding: 32px;
border: 1px solid rgb(255 255 255 / .18);
border-radius: 24px;
background: rgb(255 255 255 / .08);
opacity: 0;
transform: translateY(40px);
transition:
opacity .6s ease,
transform .6s ease;
}
.reveal-card.is-visible {
opacity: 1;
transform: translateY(0);
}
.reveal-card span {
color: #ffd329;
font-size: 14px;
font-weight: 800;
}
.reveal-card h2 {
margin: 12px 0;
font-size: clamp(28px, 5vw, 52px);
}
.reveal-card p {
margin: 0;
color: rgb(255 255 255 / .72);
line-height: 1.8;
}
@media (prefers-reduced-motion: reduce) {
.reveal-card {
opacity: 1;
transform: none;
transition: none;
}
}
JS
const targets = document.querySelectorAll(".reveal-card");
const observer = new IntersectionObserver(
(entries, observer) => {
entries.forEach((entry) => {
if (!entry.isIntersecting) return;
entry.target.classList.add("is-visible");
observer.unobserve(entry.target);
});
},
{
root: null,
rootMargin: "0px 0px -15% 0px",
threshold: 0.2,
}
);
targets.forEach((target) => {
observer.observe(target);
});
実際にやってみた
See the Pen JavaScriptで要素が画面に入ったらアニメーションする方法 by dipcode_kj (@dipcode_kj) on CodePen.
よくある失敗
- 対象要素が存在する前にobserveする:DOM読み込み後、または対象を生成した後に監視を開始します。
- thresholdを1にして発火しない:要素全体が見えないと発火しないため、大きな要素では条件が厳しすぎます。
- rootの指定を間違える:内側スクロールなら、そのスクロールコンテナを
rootにします。 - 一度だけでよいのに監視し続ける:表示後に
unobserve()して監視対象を減らします。 - アニメーション前の状態がない:CSS側に初期状態と表示状態の両方が必要です。
- prefers-reduced-motionを無視する:動きを減らす設定のユーザーには演出を抑えます。
アクセシビリティとパフォーマンス
スクロール演出は見た目を良くできますが、本文を読む邪魔になるほど強く動かすと逆効果です。表示が遅すぎる、動きが長すぎる、透明なまま読めない、といった状態を避けてください。
@media (prefers-reduced-motion: reduce) {
.reveal {
opacity: 1;
transform: none;
transition: none;
}
}
また、IntersectionObserverは「だいたい何割見えたか」を扱うAPIです。ピクセル単位で厳密な当たり判定をしたい用途には向きません。スクロール演出、遅延読み込み、目次の現在地表示など、交差状態が分かれば十分な用途に使うのが向いています。
まとめ
IntersectionObserverを使うと、スクロール位置の手計算を減らし、要素が画面に入ったタイミングで自然にアニメーションを開始できます。基本はobserve()で監視し、entry.isIntersectingを見てクラスを付けるだけです。
一度だけの演出ならunobserve()、早めに発火したいならrootMargin、表示割合を調整したいならthresholdを使います。CSS側では初期状態、表示状態、モーション軽減設定まで用意しておくと、実務でも扱いやすい実装になります。


