この記事では、Intersection Observer を使ったスクロール連動のフェードイン・スライドインを紹介します。コピペで動くHTML・CSS・JSと、カスタマイズのポイントを解説します。実際の動きはデモとソースコードで確認できます。

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • Intersection Observer で「画面に入ったら発火」を軽く実装する方法
  • 出現方向(上・下・左・右・動きなし)を属性だけで切り替える設計
  • 要素ごとに表示の時間差(遅延)を付ける書き方
  • JSが動かない環境でもコンテンツが消えない安全な組み立て方

このスニペットで作れるもの

スクロールして画面に入った要素が、透明+少しずれた位置から、本来の位置へふわっと出現するアニメーションです。属性を付けるだけで対象を増やせるシンプルな実装になっています。

動作の特徴は次の通りです。

  • 画面に入ったタイミングで自動的に出現(属性を付けるだけで対象を増やせる)
  • 出現方向を「上から・下から・左から・右から・動きなし(純フェード)」から選べる
  • 要素ごとに表示開始の遅延を付けられる(隣同士で少し時間差を出せる)
  • 一度表示したらそれ以降は再生しない(上下にスクロールしても出現は1回だけ)
  • 動きを減らすOS設定が有効なときはアニメーションせず即時表示
  • JSが動かない環境ではコンテンツが最初から表示される(消えない)

HTMLの構造を見てみよう

まずはHTML全体を見てみましょう。出現させたい要素に data-fade-in 属性を付けるだけで、監視の対象になります。

HTML
<div class="c-fade">
  <div class="c-fade__item" data-fade-in>
    <h2 class="c-fade__heading">スクロールで出現するカード</h2>
    <p class="c-fade__text">
      ビューポートに 15% 入った時点で <code>.is-visible</code> が付与され、
      透明+少し下にずれた状態から、本来の位置へフェードインします。
    </p>
  </div>

  <div class="c-fade__row">
    <div class="c-fade__item" data-fade-in>
      <h2 class="c-fade__heading">通常通り表示されるカード</h2>
      <p class="c-fade__text">
        遅延なし。3 枚とも同じ上方向のフェードインなので、
        <code>data-fade-delay</code> による出現タイミングの差だけを見比べられます。
      </p>
    </div>

    <div class="c-fade__item" data-fade-in data-fade-delay="150">
      <h2 class="c-fade__heading">少し遅れて表示されるカード</h2>
      <p class="c-fade__text">
        <code>data-fade-delay="150"</code> で、表示開始を 150ms 遅らせています。
        隣のカードよりわずかに後から出現します。
      </p>
    </div>

    <div class="c-fade__item" data-fade-in data-fade-delay="300">
      <h2 class="c-fade__heading">さらに遅れて表示されるカード</h2>
      <p class="c-fade__text">
        <code>data-fade-delay="300"</code> でさらに遅延。属性に数値を振り分けるだけで、
        連続感のある演出にできます(連番遅延を自動で配る応用は別パターンで扱います)。
      </p>
    </div>
  </div>

  <div class="c-fade__item" data-fade-in data-fade-direction="left">
    <h2 class="c-fade__heading">左からスライドインするカード</h2>
    <p class="c-fade__text">
      <code>data-fade-direction="left"</code> で、左方向からの
      スライドインに切り替わります。方向は属性だけで指定でき、JS の変更は不要です。
    </p>
  </div>

  <div class="c-fade__item" data-fade-in data-fade-direction="right">
    <h2 class="c-fade__heading">右からスライドインするカード</h2>
    <p class="c-fade__text">
      出現方向を要素ごとに変えても、監視ロジックは共通のままです。
      レイアウトやデザインに合わせて方向だけを差し替えられます。
    </p>
  </div>

  <div class="c-fade__item" data-fade-in data-fade-direction="none">
    <h2 class="c-fade__heading">動きなしで純粋にフェードするカード</h2>
    <p class="c-fade__text">
      <code>data-fade-direction="none"</code><code>transform</code> を使わず、
      <code>opacity</code> だけでふわっと現れる純フェードです。位置の移動を伴いません。
      一度表示したら監視を解除するため、上下に何度スクロールしても再アニメーションはしません(ワンショット表示)。
    </p>
  </div>
</div>
OPEN

構造の要点は次の3つです。

  • 出現させたい要素に data-fade-in 属性を付けるだけで監視対象になる
  • 出現方向は data-fade-direction、遅延は data-fade-delay という属性で要素ごとに指定する
  • JS側のコードは触らず、HTMLの属性だけで挙動を変えられる

属性で挙動を指定する

挙動を切り替える属性は3つです。各値の詳しい説明は後述の「出現方向と遅延を属性で切り替える」で扱います。

  • data-fade-in: 監視対象であることを示すフック属性
  • data-fade-direction: 出現方向の指定
  • data-fade-delay: 出現の遅延(ミリ秒)

CSSで初期状態と表示後の状態を作る

CSSは「初期状態(隠れた状態)」と「表示後の状態」の2つを定義します。フェード機構の本質となる部分だけを抜き出すと次の通りです(カードの色や余白などの装飾は完成コードにまとめています)。

CSS
/* 出現演出のトークン
   --fade-distance: 初期位置のずらし量(スライド距離)
   --fade-duration: フェード/スライドの所要時間
   --fade-easing:   減速して止まる自然なイージング */
:root {
  --fade-distance: 24px;
  --fade-duration: 0.6s;
  --fade-easing:   cubic-bezier(0.16, 1, 0.3, 1);
}

/* 初期状態: html.js のときだけ「透明+ずらした位置」にして隠す。
   JS が動かない環境では効かないため、要素は最初から表示される。 */
.js .c-fade__item[data-fade-in] {
  opacity: 0;
  transform: translateY(var(--fade-distance));
  transition:
    opacity var(--fade-duration) var(--fade-easing),
    transform var(--fade-duration) var(--fade-easing);
  /* 不可視の間はクリック/フォーカス対象にしない(透明な要素の誤操作を防ぐ)。 */
  pointer-events: none;
  will-change: opacity, transform;
}

/* 出現方向の切り替え(初期位置のずらし方向だけを変える) */
.js .c-fade__item[data-fade-direction="down"] {
  transform: translateY(calc(var(--fade-distance) * -1));
}

.js .c-fade__item[data-fade-direction="left"] {
  transform: translateX(calc(var(--fade-distance) * -1));
}

.js .c-fade__item[data-fade-direction="right"] {
  transform: translateX(var(--fade-distance));
}

/* 純フェード(動きなし): transform を当てず opacity だけで出現させる。
   transition も opacity だけにして無駄な合成を避ける。 */
.js .c-fade__item[data-fade-direction="none"] {
  transform: none;
  transition: opacity var(--fade-duration) var(--fade-easing);
  will-change: opacity;
}

/* 最終状態: translate を 0 に戻し、本来の位置へ。
   will-change も auto に戻し、合成レイヤーが残り続けるのを防ぐ。 */
.js .c-fade__item.is-visible {
  opacity: 1;
  transform: translate(0, 0);
  /* 表示されたら操作可能に戻す。 */
  pointer-events: auto;
  will-change: auto;
}

/* prefers-reduced-motion: 動きを減らす設定が有効なときは
   演出を行わず、即時に最終状態(表示)で見せる。 */
@media (prefers-reduced-motion: reduce) {
  .js .c-fade__item[data-fade-in] {
    opacity: 1;
    transform: none;
    transition: none;
    /* 即時表示されるため、表示と同時に操作可能にする。 */
    pointer-events: auto;
    will-change: auto;
  }
}
OPEN

押さえておきたいのは次の3点です。

  • 距離・時間・イージングを3つのカスタムプロパティ(--fade-distance / --fade-duration / --fade-easing)で一元管理する
  • 初期状態(opacity: 0translate)は html.js が付いたときだけ適用するので、JSが動かない環境では要素が最初から見える
  • 出現方向は初期 translate の向きを属性ごとに変えるだけ。最終状態は .is-visible クラス1つに集約する

なぜ初期状態を JS が動くときだけ適用するのか

このスニペットでは、要素を隠す初期状態のCSSを .js クラス配下に置いています。html 要素は最初 no-js で、JSが読み込まれた瞬間に js へ書き換わります。

「先に隠してから出す」という設計には落とし穴があります。もし無条件に隠すと、JSが届かない環境では要素が表示されないまま残ってしまいます。隠す処理を .js 配下に限定することで、JSが落ちてもコンテンツが消えず、最初から表示される安全側の挙動になります。

このように「動かない環境ではコンテンツが見える」状態を土台にして、動く環境だけ演出を上乗せする考え方を、プログレッシブエンハンスメントと呼びます。

Intersection Observer で出現を発火させる

JS全体は次の通りです。要素が画面に入ったことを検知して .is-visible を付けるだけの構成です。

JavaScript
/**
 * Intersection Observer でスクロール出現演出(フェード/スライドイン)を行う基本実装。
 *
 * 設計方針:
 * - 監視対象は data-fade-in 属性を持つ要素。HTML 側で属性を付けるだけで
 *   対象を増やせる。
 * - 状態は .is-visible クラスを真実の源(single source of truth)とし、
 *   CSS が .is-visible セレクタで見た目(opacity / transform)を切り替える。
 * - ワンショット表示: 一度可視になった要素は unobserve して監視を解除する。
 *   これにより上下スクロールでの再アニメーションと多重発火を防ぐ。
 *
 * プログレッシブエンハンスメント / フォールバック:
 * - 初期状態の「隠す」スタイルは CSS 側で html.js のときだけ適用される。
 *   このスクリプトは末尾で対象要素に .is-visible を付け得るが、
 *   そもそも IO 非対応・JS 無効環境では html が no-js のままになり、
 *   要素は最初から表示される(コンテンツが消えない)。
 * - IntersectionObserver 非対応ブラウザでは、全要素を即時に表示状態にする。
 */

(function () {
  "use strict";

  // 監視対象を取得(同一ページ内に複数あっても動作する)
  const targets = document.querySelectorAll("[data-fade-in]");

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

  /**
   * 1要素を最終状態(表示)にする
   * @param {HTMLElement} el - 対象要素
   */
  function reveal(el) {
    // data-fade-delay(ms)が指定されていれば、その分だけ表示開始を遅らせる。
    // 値が無い・数値でない場合は遅延 0 として扱う。
    const delay = Number(el.dataset.fadeDelay) || 0;

    if (delay > 0) {
      window.setTimeout(function () {
        el.classList.add("is-visible");
      }, delay);
    } else {
      el.classList.add("is-visible");
    }
  }

  // IntersectionObserver 非対応環境のフォールバック:
  // 監視せず、全要素を即時に表示状態にする(隠れたまま残るのを防ぐ)。
  if (!("IntersectionObserver" in window)) {
    targets.forEach(function (el) {
      el.classList.add("is-visible");
    });
    return;
  }

  /**
   * 観測オプション
   * - threshold: 0.15
   *     要素の 15% がビューポートに入った時点で発火する。
   *     0(1px でも入れば発火)だと、画面下端にかすめた瞬間に出てしまい
   *     演出として認識されにくい。逆に高すぎると大きい要素が
   *     画面に収まらず発火しないため、視認と確実な発火のバランスで 0.15 とする。
   * - rootMargin: "0px 0px -10% 0px"
   *     ビューポート下端を 10% 内側に詰めて判定する。要素が画面の
   *     やや上に入ってから出現させることで、スクロールに対して
   *     自然なタイミングになる(下端ギリギリでの発火を避ける)。
   */
  const observer = new IntersectionObserver(
    function (entries, obs) {
      entries.forEach(function (entry) {
        if (!entry.isIntersecting) return;

        reveal(entry.target);

        // ワンショット表示: 一度表示したら監視を解除する。
        // これにより再交差時の多重発火を防ぎ、不要な監視も止める。
        obs.unobserve(entry.target);
      });
    },
    {
      threshold: 0.15,
      rootMargin: "0px 0px -10% 0px",
    }
  );

  // 全対象を監視に登録
  targets.forEach(function (el) {
    observer.observe(el);
  });
})();
OPEN

設計の柱は3つです。

  • scroll イベントではなく Intersection Observer を使う: 要素がビューポートに入ったかをブラウザが効率的に判定するため、自分で位置計算を書くより軽くなります
  • 状態は .is-visible クラスに集約: JSは「見えたら .is-visible を付ける」だけで、見た目の切り替えはCSSが担います
  • ワンショット表示: 一度可視になった要素は unobserve で監視を解除し、再交差での多重発火を防ぎます

threshold と rootMargin で発火タイミングを調整する

発火タイミングは観測オプションの2つの数値で決まります(コード内のコメントに詳細あり)。threshold: 0.15 は要素の15%が画面に入ると発火、rootMargin: "0px 0px -10% 0px" は判定ラインを下端より少し上に詰める設定です。出現を早めたい・遅らせたいときはこの2つを触ります。

古いブラウザへのフォールバック(IO非対応時の即時表示)

Intersection Observer 非対応のブラウザでは、コード冒頭で非対応を検知し、監視せず全要素を即時表示します。また、JS自体が無効な環境では htmlno-js のままになり、要素を隠すCSS(.js 配下のルール)が効かないため、最初から表示されます。どちらのケースでもコンテンツは消えません。

出現方向と遅延を属性で切り替える

出現方向の5値(data-fade-direction)は、CSSの初期 translate の向きを切り替えることで実現しています。

  • up(既定)… 下にずらした位置から上へ
  • down … 上にずらした位置から下へ
  • left … 左にずらした位置から右へ
  • right … 右にずらした位置から左へ
  • nonetransform を使わず opacity だけの純フェード(位置の移動なし)

遅延(data-fade-delay)は、JS側が属性の数値を読み取り、setTimeout.is-visible の付与を遅らせる仕組みです。隣り合う要素に違う値を振れば、時間差で連続して出現させられます。

要素数に応じて遅延を自動で連番配分する「順次表示(stagger)」の応用は、別の記事で扱います。

動きを減らす設定への配慮(prefers-reduced-motion)

@media (prefers-reduced-motion: reduce) を使い、OSの「視差効果を減らす」設定が有効なときはフェードやスライドを行わず、即時に最終状態(表示)で見せます。

この設定は、前庭機能障害などで画面の動きに敏感なユーザーが利用していることがあります。アニメーションを切っても情報が欠けないようにしておきます。

コピペで使うための完成コード

ここまでの内容をまとめて、HTML / CSS / JS をフルで再掲します。HTMLは <head> のインラインスクリプトを含む全体です。このまま貼り付ければ動作する状態です。

HTML
<!DOCTYPE html>
<html lang="ja" class="no-js">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>スクロールフェードイン</title>

    <!--
      JS が読み込まれる前に html の class を no-js → js へ書き換える。
      head 内のインラインスクリプトで実行することで、画面描画前に
      切り替わり、初期状態のチラつき(FOUC)を防ぐ。
      JS 無効環境・このスクリプトが届かない環境では no-js のままになり、
      CSS 側で要素を最初から表示状態にする(コンテンツ消失を防ぐ)。
    -->
    <script>
      document.documentElement.classList.remove("no-js");
      document.documentElement.classList.add("js");
    </script>

    <link rel="stylesheet" href="./assets/css/style.css" />
  </head>
  <body>
    <main class="l-main">
      <section class="p-section">
        <div class="c-fade">
          <div class="c-fade__item" data-fade-in>
            <h2 class="c-fade__heading">スクロールで出現するカード</h2>
            <p class="c-fade__text">
              ビューポートに 15% 入った時点で <code>.is-visible</code> が付与され、
              透明+少し下にずれた状態から、本来の位置へフェードインします。
            </p>
          </div>

          <div class="c-fade__row">
            <div class="c-fade__item" data-fade-in>
              <h2 class="c-fade__heading">通常通り表示されるカード</h2>
              <p class="c-fade__text">
                遅延なし。3 枚とも同じ上方向のフェードインなので、
                <code>data-fade-delay</code> による出現タイミングの差だけを見比べられます。
              </p>
            </div>

            <div class="c-fade__item" data-fade-in data-fade-delay="150">
              <h2 class="c-fade__heading">少し遅れて表示されるカード</h2>
              <p class="c-fade__text">
                <code>data-fade-delay="150"</code> で、表示開始を 150ms 遅らせています。
                隣のカードよりわずかに後から出現します。
              </p>
            </div>

            <div class="c-fade__item" data-fade-in data-fade-delay="300">
              <h2 class="c-fade__heading">さらに遅れて表示されるカード</h2>
              <p class="c-fade__text">
                <code>data-fade-delay="300"</code> でさらに遅延。属性に数値を振り分けるだけで、
                連続感のある演出にできます(連番遅延を自動で配る応用は別パターンで扱います)。
              </p>
            </div>
          </div>

          <div class="c-fade__item" data-fade-in data-fade-direction="left">
            <h2 class="c-fade__heading">左からスライドインするカード</h2>
            <p class="c-fade__text">
              <code>data-fade-direction="left"</code> で、左方向からの
              スライドインに切り替わります。方向は属性だけで指定でき、JS の変更は不要です。
            </p>
          </div>

          <div class="c-fade__item" data-fade-in data-fade-direction="right">
            <h2 class="c-fade__heading">右からスライドインするカード</h2>
            <p class="c-fade__text">
              出現方向を要素ごとに変えても、監視ロジックは共通のままです。
              レイアウトやデザインに合わせて方向だけを差し替えられます。
            </p>
          </div>

          <div class="c-fade__item" data-fade-in data-fade-direction="none">
            <h2 class="c-fade__heading">動きなしで純粋にフェードするカード</h2>
            <p class="c-fade__text">
              <code>data-fade-direction="none"</code><code>transform</code> を使わず、
              <code>opacity</code> だけでふわっと現れる純フェードです。位置の移動を伴いません。
              一度表示したら監視を解除するため、上下に何度スクロールしても再アニメーションはしません(ワンショット表示)。
            </p>
          </div>
        </div>
      </section>
    </main>

    <script src="./assets/js/script.js" defer></script>
  </body>
</html>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   出現演出の距離・時間・イージングをここで
   一元管理する。
   ================================================ */
:root {
  --fade-color-text:        #2b2b2b;
  --fade-color-text-muted:  #555;
  --fade-color-border:      #e0e0e0;
  --fade-color-bg:          #ffffff;
  --fade-color-accent:      #3ba9e0;

  --fade-radius:            8px;
  --fade-card-padding-y:    24px;
  --fade-card-padding-x:    24px;

  /* 出現演出のトークン
     --fade-distance: 初期位置のずらし量(スライド距離)
     --fade-duration: フェード/スライドの所要時間
     --fade-easing:   減速して止まる自然なイージング */
  --fade-distance:          24px;
  --fade-duration:          0.6s;
  --fade-easing:            cubic-bezier(0.16, 1, 0.3, 1);
}

/* ================================================
   ベースリセット(デモページ用)
   ================================================ */
*,
*::before,
*::after {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue",
    "Hiragino Sans", "Noto Sans JP", sans-serif;
  color: var(--fade-color-text);
  background-color: #fafafa;
  line-height: 1.7;
}

code {
  font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
  font-size: 0.92em;
  padding: 0.1em 0.35em;
  background-color: #eef0f3;
  border-radius: 4px;
}

/* ================================================
   l-main / p-section: デモページのレイアウト
   ================================================ */
.l-main {
  max-width: 720px;
  margin-inline: auto;
  padding: 40px 20px;
}

.p-section__title {
  font-size: 22px;
  font-weight: 700;
  margin: 0 0 16px;
}

.p-section__text {
  margin: 0 0 24px;
  color: var(--fade-color-text-muted);
}

@media (min-width: 768px) {
  .p-section__title {
    font-size: 28px;
  }
}

/* ================================================
   c-fade: 出現演出するカードのリスト
   縦に並べ、十分なスクロール量を確保することで
   デモページ上で出現タイミングを確認できる。
   ================================================ */
.c-fade {
  display: flex;
  flex-direction: column;
  gap: 24px;

  /* スクロールで出現する様子を確認できるよう、
     ビューポートより縦に長くなる余白を確保する。 */
  padding-bottom: 60vh;
}

/* ================================================
   c-fade__item: 出現演出の対象カード
   ================================================ */
.c-fade__item {
  padding: var(--fade-card-padding-y) var(--fade-card-padding-x);
  background-color: var(--fade-color-bg);
  border: 1px solid var(--fade-color-border);
  border-left: 4px solid var(--fade-color-accent);
  border-radius: var(--fade-radius);
}

/* ================================================
   c-fade__row: カードを横一列に並べるラッパー
   モバイル幅では縦積み、768px 以上で 3 列に切り替える。
   遅延差(data-fade-delay)の効果を「同じ方向・同じ位置関係」で
   見比べられるよう、行内のカードは出現方向を揃える前提で使う。
   ================================================ */
.c-fade__row {
  display: grid;
  grid-template-columns: 1fr;
  gap: 24px;
}

@media (min-width: 768px) {
  .c-fade__row {
    grid-template-columns: repeat(3, 1fr);
  }
}

.c-fade__heading {
  margin: 0 0 8px;
  font-size: 18px;
  font-weight: 700;
}

.c-fade__text {
  margin: 0;
  color: var(--fade-color-text-muted);
}

/* ================================================
   出現演出の本体
   プログレッシブエンハンスメント:
   - JS が動く環境では html.js が付与される。
     このときだけ初期状態を「透明+ずらした位置」にし、
     .is-visible が付くまで隠す。
   - JS 無効・このスクリプトが届かない環境では html.no-js の
     ままになり、下記ルールが効かないため、要素は最初から
     通常表示される(コンテンツが消えない)。
   ================================================ */
.js .c-fade__item[data-fade-in] {
  opacity: 0;
  transform: translateY(var(--fade-distance));
  transition:
    opacity var(--fade-duration) var(--fade-easing),
    transform var(--fade-duration) var(--fade-easing);
  /* 不可視の間はクリック/フォーカス対象にしない(透明な要素の誤操作を防ぐ)。
     レイアウトは確保したまま、表示後に pointer-events を auto へ戻す。 */
  pointer-events: none;
  will-change: opacity, transform;
}

/* 出現方向の切り替え(初期位置のずらし方向だけを変える) */
.js .c-fade__item[data-fade-direction="down"] {
  transform: translateY(calc(var(--fade-distance) * -1));
}

.js .c-fade__item[data-fade-direction="left"] {
  transform: translateX(calc(var(--fade-distance) * -1));
}

.js .c-fade__item[data-fade-direction="right"] {
  transform: translateX(var(--fade-distance));
}

/* 純フェード(動きなし): transform を一切当てず、opacity だけで出現させる。
   初期状態は opacity: 0 のみ。translate を打ち消すのではなく最初から付与
   しないため、transition も opacity だけにして無駄な合成を避ける。 */
.js .c-fade__item[data-fade-direction="none"] {
  transform: none;
  transition: opacity var(--fade-duration) var(--fade-easing);
  will-change: opacity;
}

/* ビューポート進入後に付与される最終状態
   どの方向指定でも translate を 0 に戻し、本来の位置へ。
   will-change はワンショット表示の都合上、出現後はもう不要になる。
   CSS 側の最終状態で auto に戻すことで、表示後も合成レイヤーが残り
   続けるのを防ぐ(要素数が多いページでのメモリ常駐を回避)。 */
.js .c-fade__item.is-visible {
  opacity: 1;
  transform: translate(0, 0);
  /* 表示されたら操作可能に戻す。 */
  pointer-events: auto;
  will-change: auto;
}

/* ================================================
   prefers-reduced-motion: 動きを減らす設定への配慮
   前庭機能障害等で OS の「視差効果を減らす」を有効に
   しているユーザーには、フェード/スライドを行わず
   即時に最終状態(表示)で見せる。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .js .c-fade__item[data-fade-in] {
    opacity: 1;
    transform: none;
    transition: none;
    /* 即時表示されるため、表示と同時に操作可能にする
       (可視なのに操作不可になる不整合を防ぐ)。 */
    pointer-events: auto;
    will-change: auto;
  }
}
OPEN
JavaScript
/**
 * Intersection Observer でスクロール出現演出(フェード/スライドイン)を行う基本実装。
 *
 * 設計方針:
 * - 監視対象は data-fade-in 属性を持つ要素。HTML 側で属性を付けるだけで
 *   対象を増やせる。
 * - 状態は .is-visible クラスを真実の源(single source of truth)とし、
 *   CSS が .is-visible セレクタで見た目(opacity / transform)を切り替える。
 * - ワンショット表示: 一度可視になった要素は unobserve して監視を解除する。
 *   これにより上下スクロールでの再アニメーションと多重発火を防ぐ。
 *
 * プログレッシブエンハンスメント / フォールバック:
 * - 初期状態の「隠す」スタイルは CSS 側で html.js のときだけ適用される。
 *   このスクリプトは末尾で対象要素に .is-visible を付け得るが、
 *   そもそも IO 非対応・JS 無効環境では html が no-js のままになり、
 *   要素は最初から表示される(コンテンツが消えない)。
 * - IntersectionObserver 非対応ブラウザでは、全要素を即時に表示状態にする。
 */

(function () {
  "use strict";

  // 監視対象を取得(同一ページ内に複数あっても動作する)
  const targets = document.querySelectorAll("[data-fade-in]");

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

  /**
   * 1要素を最終状態(表示)にする
   * @param {HTMLElement} el - 対象要素
   */
  function reveal(el) {
    // data-fade-delay(ms)が指定されていれば、その分だけ表示開始を遅らせる。
    // 値が無い・数値でない場合は遅延 0 として扱う。
    const delay = Number(el.dataset.fadeDelay) || 0;

    if (delay > 0) {
      window.setTimeout(function () {
        el.classList.add("is-visible");
      }, delay);
    } else {
      el.classList.add("is-visible");
    }
  }

  // IntersectionObserver 非対応環境のフォールバック:
  // 監視せず、全要素を即時に表示状態にする(隠れたまま残るのを防ぐ)。
  if (!("IntersectionObserver" in window)) {
    targets.forEach(function (el) {
      el.classList.add("is-visible");
    });
    return;
  }

  /**
   * 観測オプション
   * - threshold: 0.15
   *     要素の 15% がビューポートに入った時点で発火する。
   *     0(1px でも入れば発火)だと、画面下端にかすめた瞬間に出てしまい
   *     演出として認識されにくい。逆に高すぎると大きい要素が
   *     画面に収まらず発火しないため、視認と確実な発火のバランスで 0.15 とする。
   * - rootMargin: "0px 0px -10% 0px"
   *     ビューポート下端を 10% 内側に詰めて判定する。要素が画面の
   *     やや上に入ってから出現させることで、スクロールに対して
   *     自然なタイミングになる(下端ギリギリでの発火を避ける)。
   */
  const observer = new IntersectionObserver(
    function (entries, obs) {
      entries.forEach(function (entry) {
        if (!entry.isIntersecting) return;

        reveal(entry.target);

        // ワンショット表示: 一度表示したら監視を解除する。
        // これにより再交差時の多重発火を防ぎ、不要な監視も止める。
        obs.unobserve(entry.target);
      });
    },
    {
      threshold: 0.15,
      rootMargin: "0px 0px -10% 0px",
    }
  );

  // 全対象を監視に登録
  targets.forEach(function (el) {
    observer.observe(el);
  });
})();
OPEN

カスタマイズしたい場面でよく触るのは次のポイントです。

  • 出現の移動量: --fade-distance
  • 出現の速さ・動き方: --fade-duration / --fade-easing
  • アクセントカラー等の見た目: :root のカスタムプロパティ
  • 発火の早さ: JSの threshold / rootMargin
  • 出現方向: 要素の data-fade-direction 属性(up / down / left / right / none
  • 表示の時間差: 要素の data-fade-delay 属性(ミリ秒)

よくある質問

なぜ scroll イベントではなく Intersection Observer を使うのですか?

scroll イベントは発火のたびに位置計算が必要で重くなりがちです。Intersection Observer は「画面に入ったか」をブラウザが効率的に判定するため、位置計算を書かずに済み、パフォーマンス面でも有利です。

一度出た要素が、上にスクロールして戻るとまた消えてしまいます。再生を繰り返したくありません。

このスニペットは一度表示したら監視を解除する「ワンショット表示」なので、再生は繰り返されません。逆に再生させたい場合は、unobserve をやめ、画面外で .is-visible を外す処理を足します。

出現方向や遅延を変えるのに、JavaScript を書き換える必要はありますか?

ありません。出現方向は data-fade-direction、遅延は data-fade-delay という属性で、HTML側だけで指定できます。

子要素を順番に時間差で出したいです。

data-fade-delay を要素ごとに振り分ければ時間差を作れます。遅延を自動で連番配分する「順次表示(stagger)」は別の記事で扱います。

まとめ

Intersection Observer を使ったスクロール連動のフェードイン・スライドインを紹介しました。要点は次の3つです。

  • 監視は Intersection Observer に任せ、見た目の切り替えは .is-visible クラスに集約する(JSとCSSの役割分担)
  • 出現方向・遅延はHTMLの属性だけで切り替えられる(JS / CSSは共通のまま)
  • JSが動かない環境への備えと、動きを減らす設定への配慮を最初から組み込む

まずはコピペして動かし、threshold の数値を変えて発火タイミングの違いを試してみてください。

関連記事