HOME / BLOG
BLOG

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.isIntersectingentry.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側では初期状態、表示状態、モーション軽減設定まで用意しておくと、実務でも扱いやすい実装になります。

参考資料