この記事では、画面に固定した背景がスクロール量に連動して徐々にぼけていく演出を紹介します。CSSだけで書ける新しい方法(animation-timeline: scroll())と、全ブラウザで動く従来のJS(scroll イベント+requestAnimationFrame)を両方使い、対応・非対応のどちらでも動く形にします。コピペで動くHTML・CSS・JSとカスタマイズのポイントを解説します。

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • CSSの animation-timeline: scroll() でJSなしに背景ぼかしを動かす方法
  • 未対応ブラウザ向けに、ぼかし量を計算して動かすJSフォールバックの組み立て方
  • @supports でCSSとJSを排他に切り替え、二重に動かさない設計
  • 背景ぼかしで動きを減らす設定にどう配慮するか(装飾なので完全に止める)

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

    画面に固定した背景が、先頭の1画面分をスクロールするにつれて徐々にぼけていきます。ぼけきった先に第2セクションの見出しが現れる、ヒーロー演出に使えるUIです。

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

    • 背景は画面全幅で固定され、スクロールしても止まったまま
    • スクロール量0→最大に連動して、ぼかしが0→最大に強まる
    • ぼけきった後は、その強さを保つ
    • 対応ブラウザではCSSだけで動く(JSは一切関与しない)
    • 未対応ブラウザでは、付属のJSが同じぼかし量を計算して動かす
    • 動きを減らすOS設定が有効なときは、ぼかしを完全に止めて背景をシャープなまま見せる
    • ぼけた背景の先で、第2セクションの見出しがフェードインして現れる

    HTMLの構造を見てみよう

    まずは背景ぼかしステージのHTMLを見てみましょう。背景画像を貼る層と、その上に重ねてぼかすオーバーレイ層の2層構造です。続けて、ぼけた先に現れる第2セクションの見出しがあります。

    HTML
    <section class="c-blur">
      <div class="c-blur__bg" aria-hidden="true"></div>
      <div class="c-blur__overlay" data-scroll-blur aria-hidden="true"></div>
    </section>
    
    <section class="p-reveal">
      <hgroup class="p-reveal__group">
        <h2 class="p-reveal__title" data-fade-in>背景をぼかすスクロール演出</h2>
        <p class="p-reveal__sub" data-fade-in>Scroll blur effect</p>
      </hgroup>
    </section>

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

    • ぼかしステージは「背景レイヤー」と「ぼかしオーバーレイ」の2層に分ける
    • data-scroll-blur をぼかしを駆動する対象(オーバーレイ)を示すフック属性にする
    • 背景・オーバーレイは装飾なので aria-hidden="true" で支援技術から隠す

    背景レイヤーとぼかしオーバーレイの2層に分ける理由

    背景画像を貼る層(.c-blur__bg)と、その上に重ねて直下の背景をぼかすオーバーレイ層(.c-blur__overlay)に役割を分けます。背景画像自体はぼかさず、オーバーレイの backdrop-filter が直下の背景をぼかします。ぼかし量はオーバーレイだけが担うため、CSS・JSのどちらの経路でもオーバーレイのぼかしを動かすだけで済みます。

    data-scroll-blur はぼかし専用のフック属性です。出現演出・進捗バー・視差などで使う属性とは別系統にしているため、同じページに他のスクロール演出を同居させても干渉しません。

    背景もオーバーレイも純粋な装飾なので、aria-hidden="true" を付けて支援技術からは隠します。

    背景を画面全幅に固定する仕組み(sticky とフルブリード)

    このスニペットのぼかしステージは、position: sticky で先頭ビューポートに貼り付けます。

    CSS
    /* ぼかしステージ。背景・オーバーレイを重ねる枠。
       sticky で先頭ビューポートに貼り付け、その間ページ全体はスクロール
       できるようにする。これにより「背景は止まったまま、スクロール量に
       応じてぼけていく」体験になる。 */
    .c-blur {
      position: sticky;
      inset-block-start: 0;
      height: 100vh;
      /* 親コンテナ(max-width 制限あり)を突き抜けてビューポート全幅にする。 */
      width: 100vw;
      margin-inline: calc(50% - 50vw);
      /* backdrop-filter の塗りをステージ内に閉じ込めてコストを抑える。 */
      contain: paint;
      overflow: clip;
    }
    OPEN

    position: sticky でステージを先頭ビューポートに貼り付けると、その間ページ全体はスクロールできます。これで「背景は止まったまま、スクロール量に応じてぼけていく」体験になります。

    width: 100vwmargin-inline: calc(50% - 50vw) で、最大幅の制限がある親コンテナを突き抜けて、背景をビューポート全幅(フルブリード)に広げます。contain: paint は、コストの高い backdrop-filter の塗りをステージ内に閉じ込めて、周囲への再描画の波及を抑えるためのものです。

    CSSだけでスクロール量に連動してぼかす(animation-timeline)

    ここが本記事の主役です。animation-timeline: scroll() を使うと、JSを一切書かずにスクロール量と backdrop-filter を連動させられます。ぼかし本体に関わるCSSは次の通りです(デモ用の装飾は完成コードにまとめています)。

    CSS
    /* ぼかしオーバーレイ。背景の上に重ね、backdrop-filter で直下の背景をぼかす。
       初期は blur(0)=ぼかしなし(何も効かなくても背景はシャープ)。 */
    .c-blur__overlay {
      position: absolute;
      inset: 0;
      -webkit-backdrop-filter: blur(0);
      backdrop-filter: blur(0);
      /* ぼかしの合成を最適化する(backdrop-filter の変化を予告)。 */
      will-change: backdrop-filter;
    }
    
    /* ぼかし 0 → 最大(--blur-max)へ強めるキーフレーム。 */
    @keyframes c-blur-deepen {
      from {
        -webkit-backdrop-filter: blur(0);
        backdrop-filter: blur(0);
      }
      to {
        -webkit-backdrop-filter: blur(var(--blur-max));
        backdrop-filter: blur(var(--blur-max));
      }
    }
    
    @supports (animation-timeline: scroll()) {
      .c-blur__overlay[data-scroll-blur] {
        animation: c-blur-deepen linear forwards;
        /* ルート(ページ全体)の縦スクロール進捗をタイムラインに使う。 */
        animation-timeline: scroll(root block);
        /* 駆動区間を先頭ビューポート相当(先頭 0 〜 100vh)に限定。 */
        animation-range: 0 100vh;
        /* スクロールタイムラインでは duration は意味を持たないが、
           ショートハンドの省略時挙動のばらつきを避けるため明示しておく。 */
        animation-duration: auto;
      }
    }
    OPEN

    scroll() がスクロール量をタイムラインに変換する

    通常のアニメーションは「時間」が経つと進みます。これに対して scroll() は「スクロール位置」を進行軸にします。つまり、時間ではなくスクロール量に応じてアニメーションが進む仕組みです。

    scroll(root block)root はページ全体(最も外側のスクロールコンテナ)、block は縦方向を指します。ページの縦スクロール進捗がタイムラインに割り当てられ、それが c-blur-deepen キーフレームの blur(0)→blur(最大) に対応します。

    スクロールタイムラインでは進行が時間ではなくスクロール位置で決まるため、animation-duration の秒数は意味を持ちません。ショートハンドの省略時挙動のばらつきを避けるため auto を明示しています。

    駆動区間を先頭ビューポートに限定する(animation-range: 0 100vh)

    animation-range: 0 100vh は、「先頭の1画面分をスクロールし切るまで」だけをぼかしの駆動区間に切り出す指定です。先頭の 0 から 100vh(ビューポート1画面分)までをスクロールする間にぼかしが0→最大へ進み、先頭ビューポートを抜けた時点でぼかしが最大になります。

    ぼけきった強さを保つ(animation-fill-mode: forwards)

    このスニペットでは animation: c-blur-deepen linear forwards のように forwards を指定しています。これは、駆動区間(100vh)を超えてもぼかしを blur(0) に戻さず、最終キーフレーム(最大ぼかし)を保持するためです。forwards を付けないと、先頭ビューポートを超えた瞬間にぼかしが消えてしまいます。

    全ブラウザで動かすためのJSフォールバック(@supports で両建て)

    animation-timeline に未対応のブラウザでは、上の @supports ブロックが無効になり、ぼかしは blur(0) のまま静止します。そこで、先頭ビューポートのスクロール進捗を計算してCSSカスタムプロパティ --blur-amount(ぼかし量px)へ書き込み、CSS側がそれを backdrop-filter に反映する経路を用意します。

    CSSは @supports で実装を排他に切り替えます。

    JavaScript
    /* 未対応ブラウザ向け: JS が書き込む --blur-amount(px)を
       backdrop-filter に反映する。 */
    @supports not (animation-timeline: scroll()) {
      .c-blur__overlay[data-scroll-blur] {
        -webkit-backdrop-filter: blur(var(--blur-amount, 0px));
        backdrop-filter: blur(var(--blur-amount, 0px));
      }
    }

    背景ぼかしのフォールバックを担うJSは次の通りです。

    JavaScript
    (function setupBlurFallback() {
      const overlay = document.querySelector("[data-scroll-blur]");
      if (!overlay) return;
    
      // ぼかしは装飾。OS の「動きを減らす」設定が有効なら、ぼかしを完全に
      // 無効化しリスナーを張らない(背景は CSS 側で blur(0) に固定される)。
      const reduceMotion =
        typeof window.matchMedia === "function" &&
        window.matchMedia("(prefers-reduced-motion: reduce)").matches;
      if (reduceMotion) return;
    
      // CSS の animation-timeline: scroll() が使える環境では CSS に任せ、
      // JS は何もしない(二重駆動の防止)。判定キーは CSS の @supports と一致。
      const cssDriven =
        typeof window.CSS !== "undefined" &&
        typeof window.CSS.supports === "function" &&
        window.CSS.supports("animation-timeline", "scroll()");
      if (cssDriven) return;
    
      // ---- ここから JS フォールバック(animation-timeline 非対応環境)----
    
      const root = document.documentElement;
    
      // ぼかしの最大強度(px)。:root の --blur-max から読み取り、CSS の
      // scroll() 経路と同じ振れ幅にする。取れない/不正なら 20px。
      function readMaxBlur() {
        const raw = getComputedStyle(root).getPropertyValue("--blur-max").trim();
        const value = parseFloat(raw);
        return Number.isFinite(value) && value >= 0 ? value : 20;
      }
    
      let maxBlur = readMaxBlur();
    
      // 多重 rAF 予約を防ぐフラグ。
      let ticking = false;
    
      // 先頭ビューポートのスクロール進捗(0〜1)を計算する。
      // 進捗 = scrollTop / viewportHeight をクランプ。
      function getProgress() {
        const scrollTop = window.scrollY || root.scrollTop || 0;
        const viewportH = window.innerHeight || root.clientHeight || 0;
    
        // 0 除算ガード: ビューポート高が取れない場合は進捗 0。
        if (viewportH <= 0) return 0;
    
        const ratio = scrollTop / viewportH;
        if (ratio < 0) return 0;
        if (ratio > 1) return 1;
        return ratio;
      }
    
      // 進捗からぼかし量を求め、--blur-amount(px)へ反映する。
      function update() {
        const amount = getProgress() * maxBlur;
        overlay.style.setProperty("--blur-amount", amount.toFixed(2) + "px");
        ticking = false;
      }
    
      // scroll / resize ハンドラ。計算は rAF に遅延させ、1 フレーム 1 回だけ走らせる。
      function onScroll() {
        if (ticking) return;
        ticking = true;
        window.requestAnimationFrame(update);
      }
    
      function onResize() {
        maxBlur = readMaxBlur();
        onScroll();
      }
    
      // 初期表示時にも一度反映しておく。
      update();
    
      window.addEventListener("scroll", onScroll, { passive: true });
      window.addEventListener("resize", onResize, { passive: true });
    })();
    OPEN

    ぼかし量の計算(進捗のクランプと0除算ガード)

    ぼかし量は次の手順で求めます。

    進捗 = スクロール量 ÷ ビューポートの高さ

    先頭の1画面分をスクロールし切った時点で進捗が1(=ぼかし最大)になり、CSSの animation-range: 0 100vh と対応します。ビューポートの高さが取れない(0以下)ときは viewportH <= 0 を先にチェックして0を返し、0除算を防ぎます。進捗は端での丸め誤差を考慮して0〜1にクランプします。

    ぼかし量は「進捗 × 最大ぼかし」で求めます。最大ぼかしは --blur-max から読み取るため、CSS経路とJS経路でぼかしの振れ幅が揃います。

    scrollイベントを requestAnimationFrame で間引く(rAFスロットル)

    scroll イベントはスクロール中に高頻度で発火します。発火のたびに計算すると重くなるため、requestAnimationFrame を使ってフレーム同期に間引きします。

    仕組みは ticking フラグです。onScroll が呼ばれたとき、まだ更新を予約していなければ requestAnimationFrame(update) を1回だけ予約し、tickingtrue にします。update が走り終えると tickingfalse に戻します。これにより、連続発火しても1フレームにつき更新は1回だけになります。リスナーは passive: true で登録し、スクロール自体をブロックしないようにしています。

    CSSとJSの二重駆動を防ぐ

    CSSとJSの両方が同時にぼかしを動かすと、二重駆動になってしまいます。これを防ぐため、JSの冒頭で CSS.supports('animation-timeline', 'scroll()') を判定し、CSSだけで動く環境では scroll リスナーを一切張らずに早期 return します。

    結果として、CSSがぼかしを担う環境ではJSは何もせず、JSが動くのは未対応環境だけ、という役割分担になります。なお背景は、対応・非対応・JS無効のいずれの環境でも常に表示されます。ぼかしが効かない場合でも、背景はシャープなまま見えるだけです。

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

    背景ぼかしは「どこまで読んだか」を示す情報提示ではなく、装飾的な演出です。スクロールに連動して画面全体がぼけていく動きは画面酔いを誘発しやすいため、動きを減らす設定が有効なときはぼかしを完全に無効化し、背景をシャープなまま見せます。止めても情報は失われません。

    CSS
    @media (prefers-reduced-motion: reduce) {
      .c-blur__overlay[data-scroll-blur] {
        animation: none;
        -webkit-backdrop-filter: blur(0);
        backdrop-filter: blur(0);
      }
    }

    CSS経路は animation: none でぼかしアニメを止め、backdrop-filter: blur(0) に固定します。JS経路も先ほどのコードの通り、動きを減らす設定ではリスナーを張らずに早期 return するため、ぼかし量は0のままです。CSSとJSの両方で二重に止めています。

    背景の先で見出しをフェードインさせる(Intersection Observer)

    ぼけきった背景の先に現れる第2セクションの見出しは、Intersection Observer で一度だけフェードインさせています。ぼかしとは独立した補助の演出です。

    JavaScript
    (function setupFadeIn() {
      const targets = document.querySelectorAll("[data-fade-in]");
      if (targets.length === 0) return;
    
      function reveal(el) {
        el.classList.add("is-visible");
      }
    
      // IntersectionObserver 非対応環境のフォールバック:
      // 監視せず、全要素を即時に表示状態にする(隠れたまま残るのを防ぐ)。
      if (!("IntersectionObserver" in window)) {
        targets.forEach(function (el) {
          reveal(el);
        });
        return;
      }
    
      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

    要素がビューポートに入ったら表示状態のクラス(.is-visible)を付け、表示後は unobserve で監視を解除します(ワンショット)。Intersection Observer非対応・JS無効の環境では、見出しは最初から表示されるため、コンテンツが消えることはありません。

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

    ここまでの内容をまとめて、HTML / CSS / JS をフルで再掲します。HTMLは <head> のインラインスクリプトを含む全体です。背景画像のパス(../img/blur-bg.jpg)はお使いの画像に差し替えてください。このまま貼り付ければ動作する状態です。

    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 内のインラインスクリプトで実行することで、画面描画前に切り替わる。
    
          ただし、この背景ぼかしは出現演出(フェードイン等)と性質が異なる。
          出現演出は「JS が要素を表示状態にしないとコンテンツが隠れたまま」になる
          ため、JS 前提を no-js / js で厳密に切り分ける必要があった。
          一方この背景ぼかしは、対応ブラウザでは CSS の animation-timeline: scroll()
          だけでスクロール量に連動して backdrop-filter が強まるため、JS は「非対応
          ブラウザ向けの保険」にすぎない。よって no-js / js をぼかしの有無には
          使わない(背景は対応・非対応・JS無効のいずれでも常に表示され、ぼかしが
          効かない場合はシャープなまま見える)。
          (no-js / js クラス自体は同シリーズの作法に揃えて付与しておく。)
        -->
        <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-intro">
            <h1 class="p-intro__title">スクロール連動の背景ぼかし</h1>
    
            <p class="p-intro__text">
              画面に固定した背景画像が、<strong>スクロール量に連動して
              徐々にぼけていく</strong>実装です。
            </p>
          </section>
    
          <!--
            背景ぼかしステージの基本構造。
              .c-blur          : 背景画像を画面に貼り付けるステージ(sticky・100vh)。
                                 ビューポート全幅(100vw)に突き抜ける。
              .c-blur__overlay : 背景の上に重ねるぼかしオーバーレイ。backdrop-filter で
                                 直下の背景をぼかす。ぼかし量はこのレイヤーが担う。
    
            data-scroll-blur   : ぼかしを駆動する対象(オーバーレイ)を示すフック属性。
              他のスクロール系スニペットと別系統の独立した属性にしているため、
              同じページに同居しても互いに干渉しない。
    
            ぼかしは純粋な装飾的エンハンスメント。CSS も JS も効かない環境では
            背景はシャープに表示され、テキストは通常どおり読める(情報欠落なし)。
          -->
          <section class="c-blur">
            <div class="c-blur__bg" aria-hidden="true"></div>
            <div class="c-blur__overlay" data-scroll-blur aria-hidden="true"></div>
          </section>
    
          <!--
            第2セクション: ビューポート進入でフェードインする見出し。
            背景がぼけきった先に現れる見せ場。出現演出は別系統の
            data-fade-in / .is-visible で制御する(ぼかしとは独立)。
          -->
          <section class="p-reveal">
            <hgroup class="p-reveal__group">
              <h2 class="p-reveal__title" data-fade-in>背景をぼかすスクロール演出</h2>
              <p class="p-reveal__sub" data-fade-in>Scroll blur effect</p>
            </hgroup>
          </section>
        </main>
    
        <script src="./assets/js/script.js" defer></script>
      </body>
    </html>
    OPEN
    CSS
    /* ================================================
       カスタムプロパティ
       背景ぼかし専用のトークン名(--blur-*)でまとめる。
       他のスクロール系スニペットと別系統のトークン名にしているため、
       同じページに複数を同居させてもトークンが衝突しない。
       ================================================ */
    :root {
      --blur-color-text:        #f4f6fa;
      --blur-color-text-muted:  #51596b;
      --blur-color-bg:          #ffffff;
      --blur-color-border:      #d9dde6;
      --blur-color-accent:      #5c6b8a;
    
      --blur-radius:            10px;
    
      /* 背景ぼかしのトークン
         --blur-max:    ぼかしの最大強度(px)。スクロール最深部での blur 量。
                        CSS の scroll() 経路・JS フォールバック経路のどちらも
                        この値を使い、両経路で見た目の振れ幅を揃える。
         --blur-amount: JS フォールバックが書き込む現在のぼかし量(px)。
                        CSS の scroll() 経路では使わない(CSS が直接駆動する)。 */
      --blur-max:               20px;
      --blur-amount:            0px;
    
      /* 出現演出のトークン(第2セクションのフェードイン用)。 */
      --blur-fade-distance:     24px;
      --blur-fade-duration:     0.6s;
      --blur-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: #2b2b2b;
      background-color: #f4f6fa;
      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: #e7eaf1;
      border-radius: 4px;
    }
    
    /* ================================================
       背景ぼかしステージ本体
       画面に貼り付く背景(__bg)の上にぼかしオーバーレイ(__overlay)を
       重ね、スクロール量に連動して backdrop-filter の blur を強めていく。
       ================================================ */
    
    /* ぼかしステージ。背景・オーバーレイを重ねる枠。
       sticky で先頭ビューポートに貼り付け、その間ページ全体はスクロール
       できるようにする。これにより「背景は止まったまま、スクロール量に
       応じてぼけていく」体験になる。
    
       contain: paint で、このステージの描画をサブツリー内に閉じ込める。
       backdrop-filter は合成・再描画のコストが高いため、塗りの影響範囲を
       ステージ内に限定して周囲への再描画波及を抑える。 */
    .c-blur {
      position: sticky;
      inset-block-start: 0;
      /* 先頭ビューポートに収まる高さ。背景はこの範囲に貼り付く。 */
      height: 100vh;
      /* 親コンテナ(max-width 制限のある .l-main)を突き抜けてビューポート
         全幅にする。背景画像が画面全幅を覆う。 */
      width: 100vw;
      margin-inline: calc(50% - 50vw);
      /* backdrop-filter の塗りをステージ内に閉じ込めてコストを抑える。 */
      contain: paint;
      overflow: clip;
    }
    
    /* 背景画像レイヤー(装飾なので aria-hidden)。ステージ全面を覆う。
       この画像自体はぼかさない。直上のオーバーレイが backdrop-filter で
       この背景をぼかす。 */
    .c-blur__bg {
      position: absolute;
      inset: 0;
      z-index: 0;
      background-image: url("../img/blur-bg.jpg");
      background-repeat: no-repeat;
      background-position: center;
      background-size: cover;
    }
    
    /* ぼかしオーバーレイ。背景の上に透明な層として重ね、backdrop-filter で
       直下(背景画像)をぼかす。ぼかし量だけをこのレイヤーが担う。
       フックは data-scroll-blur(出現演出・進捗バー・視差とは別系統)。
       初期は blur(0)=ぼかしなし(PE: 何も効かなくても背景はシャープ)。 */
    .c-blur__overlay {
      position: absolute;
      inset: 0;
      z-index: 1;
      /* 初期状態はぼかしなし。スクロール連動で 0 → --blur-max へ強まる。 */
      -webkit-backdrop-filter: blur(0);
      backdrop-filter: blur(0);
      /* ぼかしの合成を最適化する(backdrop-filter の変化を予告)。 */
      will-change: backdrop-filter;
    }
    
    /* ------------------------------------------------
       第一実装: CSS だけでスクロール量に連動してぼかす
       (animation-timeline: scroll())
       対応ブラウザでは、JS を一切使わずに backdrop-filter がスクロール量に
       連動して強まる。scroll(root block) はルート(ページ全体)の縦スクロール
       進捗をタイムラインに使い、animation-range で「先頭ビューポート相当
       (0 〜 100vh)」だけを駆動区間に切り出す。これにより最初の 1 画面分を
       スクロールし切った時点でぼかしが最大(--blur-max)になる。
       ------------------------------------------------ */
    @keyframes c-blur-deepen {
      from {
        -webkit-backdrop-filter: blur(0);
        backdrop-filter: blur(0);
      }
      to {
        -webkit-backdrop-filter: blur(var(--blur-max));
        backdrop-filter: blur(var(--blur-max));
      }
    }
    
    @supports (animation-timeline: scroll()) {
      .c-blur__overlay[data-scroll-blur] {
        /* forwards: range 終端(100vh)を超えても最終キーフレーム
           blur(var(--blur-max)) を保持する。
           未指定(none)だと先頭ビューポート超過で blur(0) に復帰してしまう。 */
        animation: c-blur-deepen linear forwards;
        /* ルート(ページ全体)の縦スクロール進捗をタイムラインに使う。 */
        animation-timeline: scroll(root block);
        /* 駆動区間を先頭ビューポート相当(先頭 0 〜 100vh スクロール)に限定。 */
        animation-range: 0 100vh;
        /* スクロールタイムラインでは duration はタイムラインの全長に
           マッピングされ秒数は意味を持たないが、ショートハンドの省略時
           挙動のばらつきを避けるため明示しておく。 */
        animation-duration: auto;
      }
    }
    
    /* ------------------------------------------------
       JS フォールバック: animation-timeline 非対応ブラウザ向け
       非対応環境では上の @supports ブロックが無効化され、ぼかしは
       blur(0) のまま静止する。JS が先頭ビューポートのスクロール進捗を
       計算して --blur-amount(px)を更新し、CSS 側がそれを
       backdrop-filter に反映する。
       ------------------------------------------------ */
    @supports not (animation-timeline: scroll()) {
      .c-blur__overlay[data-scroll-blur] {
        -webkit-backdrop-filter: blur(var(--blur-amount, 0px));
        backdrop-filter: blur(var(--blur-amount, 0px));
      }
    }
    
    /* ================================================
       prefers-reduced-motion: 動きを減らす設定への配慮
       背景ぼかしは装飾的な演出であり、読了率バーのような「情報提示」では
       ない。スクロールに連動して画面全体がぼけていく動きは画面酔いを
       誘発しやすいため、reduce 時はぼかしを完全に無効化し、背景を
       シャープなまま固定する。
       - CSS 経路(scroll()): animation を none にしてぼかしアニメを止める。
       - 両経路とも backdrop-filter を blur(0) に固定し、ぼかしが無効になる。
         JS 側もリスナーを張らない(後述)ため、--blur-amount は 0 のまま。
       ================================================ */
    @media (prefers-reduced-motion: reduce) {
      .c-blur__overlay[data-scroll-blur] {
        animation: none;
        -webkit-backdrop-filter: blur(0);
        backdrop-filter: blur(0);
      }
    }
    
    /* ================================================
       l-main / p-intro: デモページのレイアウト
       ================================================ */
    .l-main {
      max-width: 880px;
      margin-inline: auto;
      padding: 40px 20px;
    }
    
    .p-intro__title {
      font-size: 22px;
      font-weight: 700;
      margin: 0 0 16px;
    }
    
    .p-intro__text {
      margin: 0 0 24px;
      color: var(--blur-color-text-muted);
    }
    
    /* ================================================
       p-reveal: 第2セクション(フェードインする見出し)
       背景がぼけきった先に現れる見せ場。十分なスクロール量を
       確保するため縦に長く取り、進入で見出しがフェードインする。
       ================================================ */
    .p-reveal {
      display: flex;
      align-items: center;
      justify-content: center;
      min-height: 100vh;
      padding: 24px;
      background-color: #1a2238;
    }
    
    .p-reveal__group {
      margin: 0;
      text-align: center;
    }
    
    .p-reveal__title {
      margin: 0 0 12px;
      font-size: 24px;
      font-weight: 700;
      color: var(--blur-color-text);
    }
    
    .p-reveal__sub {
      margin: 0;
      font-size: 14px;
      letter-spacing: 0.08em;
      color: rgba(244, 246, 250, 0.7);
    }
    
    /* 出現演出の本体。
       プログレッシブエンハンスメント:
       - JS が動く環境では html.js が付与される。このときだけ初期状態を
         「透明+下にずらした位置」にし、.is-visible が付くまで隠す。
       - JS 無効・このスクリプトが届かない環境では html が no-js のままに
         なり、下記ルールが効かないため見出しは最初から表示される
         (コンテンツが消えない)。 */
    .js .p-reveal__title[data-fade-in],
    .js .p-reveal__sub[data-fade-in] {
      opacity: 0;
      transform: translateY(var(--blur-fade-distance));
      transition:
        opacity var(--blur-fade-duration) var(--blur-fade-easing),
        transform var(--blur-fade-duration) var(--blur-fade-easing);
      will-change: opacity, transform;
    }
    
    /* ビューポート進入後に付与される最終状態。translate を 0 に戻し、
       本来の位置へ。will-change はワンショット表示後はもう不要なので
       auto に戻し、合成レイヤーが残り続けるのを防ぐ。 */
    .js .p-reveal__title.is-visible,
    .js .p-reveal__sub.is-visible {
      opacity: 1;
      transform: translateY(0);
      will-change: auto;
    }
    
    /* prefers-reduced-motion: フェードインは即時最終状態で見せる。
       出現演出は reduce 時に「最初から表示」で差し支えない(情報提示では
       ないため)。 */
    @media (prefers-reduced-motion: reduce) {
      .js .p-reveal__title[data-fade-in],
      .js .p-reveal__sub[data-fade-in] {
        opacity: 1;
        transform: none;
        transition: none;
        will-change: auto;
      }
    }
    
    @media (min-width: 768px) {
      .p-intro__title {
        font-size: 28px;
      }
    
      .p-reveal__title {
        font-size: 34px;
      }
    
      .p-reveal__sub {
        font-size: 16px;
      }
    }
    OPEN
    JavaScript
    /**
     * 画面に固定した背景画像が、スクロール量に連動して徐々にぼけていく実装。
     * 加えて、続く第2セクションの見出しを Intersection Observer でフェードインさせる。
     *
     * この JS は独立した 2 つの責務を持つ:
     *   (A) 背景ぼかしのフォールバック(animation-timeline 非対応環境のみ)
     *   (B) 第2セクションのフェードイン(IO によるワンショット表示)
     * 両者は別系統のため、互いに独立して動く。
     *
     * 設計方針(A: 背景ぼかし):
     * - 第一実装は CSS の animation-timeline: scroll()。対応ブラウザでは
     *   この JS は (A) について何もしない(CSS だけでぼかしが連動する)。
     * - フォールバックは、先頭ビューポートのスクロール進捗(0〜1)を計算し、
     *   CSS カスタムプロパティ --blur-amount(px)へ書き込む。CSS 側がそれを
     *   backdrop-filter: blur() に反映する。
     *
     * 二重駆動の防止(A):
     * - CSS.supports('animation-timeline', 'scroll()') が true の環境では、
     *   CSS がぼかしを担うため scroll リスナーを一切張らない。
     *   こうして「CSS と JS が同時にぼかしを動かす」二重駆動を防ぐ。
     *   判定キー(animation-timeline: scroll())は CSS の @supports と完全一致。
     *
     * アクセシビリティ(A):
     * - prefers-reduced-motion: reduce のときはぼかしを完全に無効化する。
     *   ぼかしは装飾的な演出で画面酔いを誘発しやすいため、追従を止めて背景を
     *   シャープなまま固定する。JS 経路ではリスナーを張らず、--blur-amount も
     *   0 のままにする(CSS 側でも backdrop-filter: blur(0) に固定して二重に止める)。
     *
     * パフォーマンス(A):
     * - scroll イベントは高頻度で発火するため、毎回計算せず
     *   requestAnimationFrame でフレーム同期に間引きする(rAF スロットル)。
     *   ticking フラグで多重 rAF 予約を防ぎ、1 フレームに 1 回だけ更新する。
     * - scroll / resize リスナーは passive: true で、スクロールをブロック
     *   しないことを明示する。
     *
     * プログレッシブエンハンスメント:
     * - ぼかしは純粋な装飾。対応・非対応・JS 無効のいずれでも背景は表示され、
     *   ぼかしが効かない場合はシャープなまま見えるだけ(情報欠落なし)。
     * - フェードイン(B)も、IO 非対応・JS 無効では見出しが最初から表示される。
     */
    
    (function () {
      "use strict";
    
      /* =========================================================
         (A) 背景ぼかしのフォールバック
         ========================================================= */
      (function setupBlurFallback() {
        const overlay = document.querySelector("[data-scroll-blur]");
        if (!overlay) return;
    
        // ぼかしは装飾。OS の「動きを減らす」設定が有効なら、ぼかしを完全に
        // 無効化しリスナーを張らない(背景は CSS 側で blur(0) に固定される)。
        const reduceMotion =
          typeof window.matchMedia === "function" &&
          window.matchMedia("(prefers-reduced-motion: reduce)").matches;
        if (reduceMotion) return;
    
        // CSS の animation-timeline: scroll() が使える環境では CSS に任せ、
        // JS は何もしない(二重駆動の防止)。判定キーは CSS の @supports と一致。
        // CSS.supports 自体が無い極めて古い環境では JS フォールバックへ進む。
        const cssDriven =
          typeof window.CSS !== "undefined" &&
          typeof window.CSS.supports === "function" &&
          window.CSS.supports("animation-timeline", "scroll()");
        if (cssDriven) return;
    
        // ---- ここから JS フォールバック(animation-timeline 非対応環境)----
    
        const root = document.documentElement;
    
        // ぼかしの最大強度(px)。:root の --blur-max から読み取り、CSS の
        // scroll() 経路と同じ振れ幅にする。取れない/不正なら 20px。
        function readMaxBlur() {
          const raw = getComputedStyle(root).getPropertyValue("--blur-max").trim();
          const value = parseFloat(raw);
          return Number.isFinite(value) && value >= 0 ? value : 20;
        }
    
        let maxBlur = readMaxBlur();
    
        // 多重 rAF 予約を防ぐフラグ。scroll が連続発火しても、
        // 1 フレームにつき更新は 1 回だけにする。
        let ticking = false;
    
        /**
         * 先頭ビューポートのスクロール進捗(0〜1)を計算する。
         * 進捗 = scrollTop / viewportHeight をクランプ。
         * 先頭の 1 画面分をスクロールし切った時点で 1(=ぼかし最大)になる。
         * CSS の animation-range: 0 100vh と対応させる。
         * @returns {number} 0〜1 にクランプした進捗
         */
        function getProgress() {
          const scrollTop = window.scrollY || root.scrollTop || 0;
          const viewportH = window.innerHeight || root.clientHeight || 0;
    
          // 0 除算ガード: ビューポート高が取れない場合は進捗 0。
          if (viewportH <= 0) return 0;
    
          const ratio = scrollTop / viewportH;
          if (ratio < 0) return 0;
          if (ratio > 1) return 1;
          return ratio;
        }
    
        /**
         * 進捗からぼかし量を求め、--blur-amount(px)へ反映する。
         * 見た目(backdrop-filter)の適用は CSS 側(--blur-amount を読む)に委ねる。
         */
        function update() {
          const amount = getProgress() * maxBlur;
          overlay.style.setProperty("--blur-amount", amount.toFixed(2) + "px");
          ticking = false;
        }
    
        /**
         * scroll / resize ハンドラ。計算自体は rAF に遅延させ、
         * フレーム同期で 1 回だけ update を走らせる(rAF スロットル)。
         */
        function onScroll() {
          if (ticking) return;
          ticking = true;
          window.requestAnimationFrame(update);
        }
    
        // resize ではビューポート高とトークン値が変わり得るため、最大ぼかしを
        // 読み直してから再計算する。
        function onResize() {
          maxBlur = readMaxBlur();
          onScroll();
        }
    
        // 初期表示時にも一度反映しておく(リロード時にスクロール位置が
        // 途中だった場合に、最初から正しいぼかし量を見せる)。
        update();
    
        window.addEventListener("scroll", onScroll, { passive: true });
        window.addEventListener("resize", onResize, { passive: true });
      })();
    
      /* =========================================================
         (B) 第2セクションのフェードイン(IO ワンショット)
         ビューポート進入で一度だけ表示する。一度表示したら unobserve する。
         ========================================================= */
      (function setupFadeIn() {
        const targets = document.querySelectorAll("[data-fade-in]");
        if (targets.length === 0) return;
    
        /**
         * 1要素を最終状態(表示)にする。
         * @param {HTMLElement} el - 対象要素
         */
        function reveal(el) {
          el.classList.add("is-visible");
        }
    
        // IntersectionObserver 非対応環境のフォールバック:
        // 監視せず、全要素を即時に表示状態にする(隠れたまま残るのを防ぐ)。
        if (!("IntersectionObserver" in window)) {
          targets.forEach(function (el) {
            reveal(el);
          });
          return;
        }
    
        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

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

    • ぼかしの最大強度: --blur-max
    • 背景画像: .c-blur__bgbackground-image
    • ぼかしの駆動区間: animation-range: 0 100vh
    • 参照するスクロール対象: animation-timeline: scroll() の対象(既定はページ全体)
    • 見出しフェードインの距離・時間: --blur-fade-distance / --blur-fade-duration

    よくある質問

    animation-timeline: scroll() に対応していないブラウザでは動かないのですか?

    動きます。対応ブラウザではCSSだけで背景がぼけ、未対応ブラウザでは付属のJavaScriptがスクロール量からぼかし量を計算して背景をぼかします。@supports でCSSの実装を切り替え、JS側も対応状況を判定するため、対応・非対応のどちらでも同じ演出が機能します。ぼかしが一切効かない環境でも背景はシャープに表示され、テキストは普通に読めます。

    スクロールし切った後、ぼかしが消えてしまいます。なぜですか?

    ぼかしを保持する設定が抜けている可能性が高いです。スクロール連動のアニメーションは、駆動区間(先頭1画面分)を超えると初期状態に戻ろうとします。このスニペットでは animation-fill-mode: forwards を付けて、ぼけきった最大の強さをそのまま保つようにしています。

    動きを減らすOS設定が有効なとき、背景ぼかしはどうなりますか?

    ぼかしを完全に止めて、背景をシャープなまま見せます。背景ぼかしは装飾的な演出で、画面全体がぼけていく動きは画面酔いを誘発しやすいため、読了率を示す進捗バーのような情報提示UIとは違い、止めても情報は失われません。このスニペットでは動きを減らす設定でぼかしを無効化しています。

    まとめ

    CSSの animation-timeline: scroll() と従来のJSで両対応する、スクロール連動の背景ぼかしを紹介しました。要点は次の通りです。

    • 対応ブラウザはCSSの animation-timeline: scroll()backdrop-filter だけで動く(JS不要)
    • 未対応ブラウザは scroll イベント+requestAnimationFrame のJSで同じぼかし量を計算する
    • @supports でCSSを排他に切り替え、JSは CSS.supports 判定で二重駆動を防ぐ
    • ぼかしは装飾なので、動きを減らす設定では完全に止める
    • forwards でぼけきった強さを保つ

    まずはコピペして動かし、対応ブラウザではJSなしで動くこと、--blur-max でぼかしの強さを変えられることを確認してみてください。

    関連記事