この記事では、背景と前景に速度差をつけて奥行きを出すパララックス(視差スクロール)を紹介します。図形だけで作る方法も、実画像を使う方法も、複数レイヤーを別速度で動かす多層の方法も、同じ仕組みで作れます。CSSだけで書ける新しい方法(animation-timeline: view())と、全ブラウザで動く従来のJS(scroll イベント+requestAnimationFrame)を両方使い、対応・非対応のどちらでも動く形にします。background-attachment: fixed がスマホで効かない問題も避けられます。コピペで動くHTML・CSS・JSとカスタマイズのポイントを解説します。

🌐 デモを見る(GitHub Pages)

💻 ソースコード(GitHub)

この記事で分かること
  • CSSの animation-timeline: view() でJSなしにレイヤーを視差で動かす方法
  • 図形だけ・実画像・多層(速度差)の3パターンを同じ仕組みで作り分ける方法
  • 未対応ブラウザ向けに、視差量を計算して動かすJSフォールバックの組み立て方
  • @supports でCSSとJSを排他に切り替え、二重に動かさない設計
  • 動きを減らす設定で視差をどう扱うか(装飾なので完全に止める)
  • overflow: hidden で視差が止まる落とし穴と、その回避策

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

背景レイヤーが前景コンテンツよりゆっくり動き、両者の速度差で奥行き(視差)が生まれます。記事やLPの装飾的なビジュアル演出に使えます。同じ仕組みのまま、素材なしの図形でも実画像でも作れます。

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

  • 背景が前景より遅れて動き、奥行きを感じさせる
  • 対応ブラウザではCSSだけで動く(JSは一切関与しない)
  • 未対応ブラウザでは、付属のJSが同じ移動量を計算してレイヤーを動かす
  • 動きを減らすOS設定が有効なときは、視差を完全に止めて通常表示にする
  • 視差の素材は自由。CSSの図形だけでも、実画像でも、両者を重ねた多層でも作れる

3つの作り方(図形 / 画像背景 / 多層)

このスニペットには、同じ仕組みで作る3パターンを用意しています。違いは「何を動かすか」と「いくつのレイヤーを動かすか」だけです。

  • 図形だけの視差: 画像を使わず、CSSのグラデーションと半透明の円だけで作ります。素材がなくても視差は作れます。
  • 画像背景の視差(単層): 1枚の画像を background-image で背景に敷き、それを丸ごとゆっくり動かします。前景テキストを読みやすくするため、画像の上に半透明の暗色オーバーレイ(::after)を重ねます。
  • 多層(マルチレイヤー)の視差: 遠景と近景を別々の速度で動かします。遠景(画像)はゆっくり、近景(図形)は速く動かすと、「遠いものほどゆっくり動く」現実の見え方に近づき、段階的な立体感が出ます。速度差はレイヤーごとの --parallax-shift(移動量)で作ります。

背景に置く実画像(./assets/img/parallax-bg.jpg / ./assets/img/parallax-far.jpg)は、自分の画像に差し替えて使う前提です。図形だけのパターンは素材を用意せずそのまま動きます。

HTMLの構造を見てみよう

まずは図形パターンのHTMLを見てみましょう。前景と背景を重ねるステージの中に、ゆっくり動かす背景レイヤーと、通常速度で流れる前景コンテンツを置く構造です。

HTML
<section class="c-parallax c-parallax--a">
  <div class="c-parallax__bg" data-parallax aria-hidden="true">
    <span class="c-parallax__shape c-parallax__shape--1"></span>
    <span class="c-parallax__shape c-parallax__shape--2"></span>
  </div>
  <div class="c-parallax__fg">
    <h2 class="c-parallax__heading">奥行きセクション</h2>
    <p class="c-parallax__lead">
      背景の図形が前景の見出しよりゆっくり動くことで、背景が「奥」に
      あるように感じられます。
    </p>
  </div>
</section>

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

  • ステージ(.c-parallax)の中に、背景レイヤー(.c-parallax__bg)と前景コンテンツ(.c-parallax__fg)を重ねる
  • data-parallax を視差駆動の対象を示すフック属性にする
  • 装飾の背景は aria-hidden="true" で支援技術から隠す

画像背景のパターンは、背景レイヤーの中身を空にして(図形を置かず)CSS側で画像を敷くだけです。多層のパターンは、背景レイヤーに加えて中間レイヤー(.c-parallax__layer)にも data-parallax を付け、両方を別速度で動かします。HTMLの基本形は3パターンとも同じです。

ステージ・背景・前景の3層に分ける理由

視差ステージは、前景と背景を重ねるための枠です。この中で、背景レイヤーだけをゆっくり動かし、前景は通常のスクロール速度で流します。両者の速度差が「視差」として知覚されます。器(ステージ)と動き(背景)と中身(前景)の役割を分けておくと、対応・非対応のどちらの経路でもレイヤーの translateY を動かすだけでよくなります。

data-parallax は視差駆動専用のフック属性です。出現演出や進捗バーなどで使う属性とは別系統にしているため、同じページに他のスクロール演出を同居させても干渉しません。多層パターンでは、複数のレイヤーにこの属性を付けるだけで、それぞれを個別に動かせます。

背景レイヤーは見た目だけの装飾なので、aria-hidden="true" で支援技術から隠します。背景に意味のあるテキストは置きません。

CSSだけで視差を成立させる(animation-timeline: view())

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

CSS
@keyframes c-parallax-slide {
  from {
    transform: translateY(var(--parallax-shift));
  }
  to {
    transform: translateY(calc(var(--parallax-shift) * -1));
  }
}

@supports (animation-timeline: view()) {
  .c-parallax__bg[data-parallax] {
    animation: c-parallax-slide linear;
    /* 背景自身がビューポートを通過する進捗をタイムラインに使う。 */
    animation-timeline: view();
    /* スクロールタイムラインでは duration はタイムラインの全長に
       マッピングされるため秒数は意味を持たないが、ショートハンドの
       省略時挙動のばらつきを避けるため明示しておく。 */
    animation-duration: auto;
  }
}

view() が「要素のビューポート通過」をタイムラインに変換する

通常のアニメーションは「時間」が経つと進みます。これに対して view() は「対象要素が画面を通過する進捗」を進行軸にします。背景レイヤー自身がビューポートを通過する進捗(0%→100%)がタイムラインに割り当てられ、それが c-parallax-slide キーフレームの translateY(+shift)→translateY(-shift) に対応します。

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

移動量と天地の余白(レイヤーごとに --parallax-shift

レイヤーは下方向(+shift)から上方向(-shift)へ動きます。動いたときにレイヤーとステージの間に隙間ができないよう、天地に移動量分の余白を確保します。背景レイヤー(.c-parallax__bg)と中間レイヤー(.c-parallax__layer)で共通のスタイルです。

CSS
.c-parallax__bg,
.c-parallax__layer {
  position: absolute;
  inset-inline: 0;
  /* 移動しても隙間ができないよう、上下にそのレイヤーの shift 分の余白を確保。 */
  inset-block: calc(var(--parallax-shift) * -1) calc(var(--parallax-shift) * -1);
}

移動の総量はカスタムプロパティ --parallax-shift で管理します。重要なのは、この値をレイヤーごとに上書きできる点です。:root に既定値を置きつつ、特定のレイヤーだけ別の値にすれば、同じ仕組みのまま速度差を作れます。後述のCSS経路・JS経路の両方でこの値を使い、見た目の振れ幅を揃えます。

CSS
/* 遠景は移動量を小さく=ゆっくり=遠く見える。 */
.c-parallax--c .c-parallax__bg--far {
  --parallax-shift: 50px;
}

/* 近景は移動量を大きく=速く=近く見える。両者の差が奥行きになる。 */
.c-parallax--c .c-parallax__layer--near {
  --parallax-shift: 130px;
}

移動量が小さいほどゆっくり動いて「遠く」に、大きいほど速く動いて「近く」に見えます。これが多層パララックスの正体です。

画像背景は前景の可読性をオーバーレイで確保する

画像を背景に敷くパターンでは、画像の明るさによって前景テキストが読みづらくなります。そこで画像レイヤーの上に半透明の暗色グラデーションを ::after で重ね、前景テキストとのコントラストを確保します。

CSS
.c-parallax--b .c-parallax__bg {
  background-image: url("../img/parallax-bg.jpg");
  background-position: center;
  background-size: cover;
}

/* 画像の上・前景テキストの下に暗色オーバーレイを重ねる。 */
.c-parallax--b .c-parallax__bg::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(
    180deg,
    rgba(20, 28, 48, 0.35) 0%,
    rgba(20, 28, 48, 0.6) 100%
  );
}

多層パターンの遠景画像にも同じくオーバーレイを重ねています。画像パスは自分の画像に差し替えて使ってください。

モバイルで background-attachment: fixed を使わない理由

CSSだけで似た視差を出す手段に background-attachment: fixed があります。ただしモバイル(iOS Safari等)で非対応・ガクつきの難があります。そのため本実装では fixed を使わず、view() と(フォールバックの)transform: translateY() で視差を作ります。これによりモバイルでも動きます。

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

animation-timeline に未対応のブラウザでは、上の @supports ブロックが無効になり、レイヤーは静止します。そこで、各レイヤーの視差量を計算してCSSカスタムプロパティ --parallax-y(px)へ書き込み、CSS側がそれを translateY に反映する経路を用意します。

CSSは @supports で実装を排他に切り替えます。背景レイヤーと中間レイヤーの両方を対象にします。

CSS
@supports not (animation-timeline: view()) {
  .c-parallax__bg[data-parallax],
  .c-parallax__layer[data-parallax] {
    transform: translateY(var(--parallax-y, 0px));
  }
}

JS全体は次の通りです。

JavaScript
(function () {
  "use strict";

  const layers = document.querySelectorAll("[data-parallax]");
  if (!layers.length) return;

  // 視差は装飾。OS の「動きを減らす」設定が有効なら、視差を完全に無効化し
  // リスナーを張らない(レイヤーは CSS 側で transform: none に固定される)。
  const reduceMotion =
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reduceMotion) return;

  // CSS の animation-timeline: view() が使える環境では CSS に任せ、
  // JS は何もしない(二重駆動の防止)。判定キーは CSS の @supports と一致。
  // CSS.supports 自体が無い極めて古い環境では JS フォールバックへ進む。
  const cssDriven =
    typeof window.CSS !== "undefined" &&
    typeof window.CSS.supports === "function" &&
    window.CSS.supports("animation-timeline", "view()");

  if (cssDriven) return;

  // ---- ここから JS フォールバック(animation-timeline 非対応環境)----

  const root = document.documentElement;

  // 既定の総移動量(px)。:root の --parallax-shift から読み取り、要素側に
  // 個別指定がないレイヤーのフォールバックに使う。取れない/不正なら 120px。
  function readRootShift() {
    const raw = getComputedStyle(root)
      .getPropertyValue("--parallax-shift")
      .trim();
    const value = parseFloat(raw);
    return Number.isFinite(value) && value >= 0 ? value : 120;
  }

  // 個々のレイヤーの総移動量(px)。そのレイヤー自身の computed style から
  // --parallax-shift を読み、レイヤーごとに別の移動量(=速度)を許す。
  // 取れない/不正なら :root の既定値へフォールバックする。
  function readLayerShift(el, rootShift) {
    const raw = getComputedStyle(el).getPropertyValue("--parallax-shift").trim();
    const value = parseFloat(raw);
    return Number.isFinite(value) && value >= 0 ? value : rootShift;
  }

  let rootShift = readRootShift();

  // 多重 rAF 予約を防ぐフラグ。scroll が連続発火しても、
  // 1 フレームにつき更新は 1 回だけにする。
  let ticking = false;

  /**
   * 各視差レイヤーの視差量を計算し、要素ごとに --parallax-y(px)を更新する。
   *
   * 各レイヤーがビューポートを通過する進捗(0〜1)を求め、それを
   * +shift(画面下から入る瞬間)→ -shift(画面上へ抜ける瞬間)へ
   * 線形にマッピングする。shift はレイヤーごとに個別に読むため、遠景・近景
   * など複数レイヤーをそれぞれ別の速度で動かせる。前景は動かないため、
   * このレイヤーの移動が視差になる。
   */
  function update() {
    const viewportH = window.innerHeight || root.clientHeight || 0;

    for (let i = 0; i < layers.length; i++) {
      const layer = layers[i];
      const rect = layer.getBoundingClientRect();

      // このレイヤー固有の総移動量(速度)。要素指定がなければ root 既定へ。
      const shift = readLayerShift(layer, rootShift);

      // 要素がビューポートを通過する進捗 0〜1。
      // 要素の上端が画面下端にあるとき 0、要素の下端が画面上端に
      // 抜けるとき 1。分母 0 ガードつき。
      const span = viewportH + rect.height;
      const progress = span > 0 ? (viewportH - rect.top) / span : 0;

      // 0〜1 にクランプ(画面外で過剰な移動量にならないように)。
      const clamped = progress < 0 ? 0 : progress > 1 ? 1 : progress;

      // 進捗 0→1 を +shift → -shift にマッピング。
      const y = shift - clamped * shift * 2;
      layer.style.setProperty("--parallax-y", y.toFixed(2) + "px");
    }

    ticking = false;
  }

  /**
   * scroll / resize ハンドラ。計算自体は rAF に遅延させ、
   * フレーム同期で 1 回だけ update を走らせる(rAF スロットル)。
   */
  function onScroll() {
    if (ticking) return;
    ticking = true;
    window.requestAnimationFrame(update);
  }

  // resize ではビューポート高とトークン値が変わり得るため、root 既定の
  // shift を読み直す(各レイヤー固有値は update 内で毎回読むため不要)。
  function onResize() {
    rootShift = readRootShift();
    onScroll();
  }

  // 初期表示時にも一度反映しておく(リロード時にスクロール位置が
  // 途中だった場合に、最初から正しい視差位置を見せる)。
  update();

  window.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", onResize, { passive: true });
})();
OPEN

レイヤーごとに移動量(速度)を読む

このJSの肝は、移動量 --parallax-shiftレイヤーごとに読む点です。readLayerShift がそのレイヤー自身の値を読み、指定がなければ :root の既定値へフォールバックします。これにより、遠景はゆっくり・近景は速く、とレイヤーごとに別速度で動かせます。CSSの view() 経路と同じ振れ幅になります。

視差量の計算(通過進捗のクランプ)

各レイヤーがビューポートを通過する進捗(0〜1)を求めます。要素の上端が画面下端にあるとき0、要素の下端が画面上端へ抜けるとき1になるよう計算します。分母には要素の高さとビューポート高の合計を使い、0以下のときは進捗0を返して0除算を防ぎます。

求めた進捗は、画面外で過剰な移動量にならないよう0〜1にクランプします。そのうえで進捗0→1を +shift → -shift へ線形にマッピングし、要素ごとに --parallax-y(px)へ書き込みます。

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

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

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

CSSとJSの二重駆動を防ぐ

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

結果として、CSSが視差を担う環境ではJSは何もせず、JSが動くのは未対応環境だけ、という役割分担になります。なお各レイヤーは、対応・非対応・JS無効のいずれの場合でも常に表示されます。視差が効かないだけで、レイアウトやコンテンツは壊れません。

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

視差は「どこまで読んだか」を示す情報提示ではなく、装飾的な演出です。レイヤーが前景と速度差で動くため、画面酔いを誘発しやすい動きでもあります。そのため、動きを減らす設定が有効なときは視差を完全に無効化し、レイヤーを固定して通常フロー表示にします。止めても情報は失われません。

CSS
@media (prefers-reduced-motion: reduce) {
  .c-parallax__bg[data-parallax],
  .c-parallax__layer[data-parallax] {
    animation: none;
    transform: none;
  }
}

止め方は二重にしています。CSS経路は animation: none で視差アニメを止めます。JS経路は、冒頭で動きを減らす設定を検知したらリスナーを張らず --parallax-y を更新しません。加えてCSS側でも transform: none に固定し、取りこぼしを防ぎます。

実装でハマりやすい落とし穴(overflow: hidden と clip)

⚠️ 視差ステージのはみ出しを切り取るとき、overflow: hidden を使うと視差が動かなくなります。

overflow: hidden はそれ自体がスクロールコンテナを生成します。すると視差レイヤーから見た基準のスクロール領域がページ全体ではなくステージ自身になり、要素の通過進捗が一定値に張り付いて視差が止まります。

はみ出しを切り取りたいときは、スクロールコンテナを作らない overflow: clip を使います。これで基準のスクロール領域がページ全体に戻り、スクロールに連動して進捗が進みます。

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

ここまでの内容をまとめて、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 内のインラインスクリプトで実行することで、画面描画前に切り替わる。

      ただし、この視差スクロールは出現演出(フェードイン等)と性質が異なる。
      出現演出は「JS が要素を表示状態にしないとコンテンツが隠れたまま」になる
      ため、JS 前提を no-js / js で厳密に切り分ける必要があった。
      一方この視差は、対応ブラウザでは CSS の animation-timeline: view()
      だけで背景レイヤーがスクロールに連動して動くため、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>
          つけることで、奥行き(視差)を感じさせる実装です。ここでは
          <strong>3 種類のパターン</strong>を並べています ——
          <strong>(1) CSS の図形だけで作る視差</strong>
          <strong>(2) 1 枚の画像を背景に動かす視差</strong>
          <strong>(3) 複数レイヤーを別々の速度で動かす多層視差</strong>です。
          対応ブラウザでは <code>animation-timeline: view()</code> によって、
          各レイヤーがビューポートを通過する進捗に連動して動きます
<strong>JS なし・CSS だけ</strong>)。この機能に未対応のブラウザ
          では、<code>scroll</code> イベントと
          <code>requestAnimationFrame</code> を使った軽量な JS が同じ
          <code>translateY</code> を計算してレイヤーを動かします
          (フォールバック)。OS の「視差効果を減らす」設定が有効な場合は、
          視差を完全に無効化して通常の表示にします。下方向にスクロールして
          奥行きを確認してください。
        </p>
      </section>

      <!--
        視差レイヤーの基本構造。
          .c-parallax       : 視差を成立させるステージ(前景+背景を重ねる枠)。
          .c-parallax__bg   : 背景レイヤー。前景よりゆっくり動かして「奥」に見せる。
          .c-parallax__fg   : 前景コンテンツ。通常のスクロール速度で流れる。

        data-parallax       : 視差駆動する対象(動かすレイヤー)を示すフック属性。
          出現演出・進捗バーとは別系統の独立した属性にしているため、同じ
          ページに同居しても互いに干渉しない。複数レイヤーに付与すれば、
          レイヤーごとに別の速度で動かせる(後述の多層パターンを参照)。

        各レイヤーが動く総移動量は CSS 変数 --parallax-shift で決まる。
          レイヤーごとに別の --parallax-shift を持たせれば、同じ仕組みのまま
          「速いレイヤー/遅いレイヤー」を作り分けられる。移動量が小さいほど
          ゆっくり動いて「遠く」に、大きいほど速く動いて「近く」に見える。

        ↓ 以下、3 つの異なる視差パターンを並べる。
      -->

      <!--
        パターン 1: 図形オブジェクトの視差(画像を使わない)
          背景は外部画像に依存せず、CSS のグラデーションと装飾図形だけで
          自己完結させている。素材(画像)を用意しなくても視差が作れる例。
      -->
      <section class="c-parallax c-parallax--a">
        <div class="c-parallax__bg" data-parallax aria-hidden="true">
          <span class="c-parallax__shape c-parallax__shape--1"></span>
          <span class="c-parallax__shape c-parallax__shape--2"></span>
        </div>
        <div class="c-parallax__fg">
          <h2 class="c-parallax__heading">パターン 1: 図形だけの視差</h2>
          <p class="c-parallax__lead">
            画像を一切使わず、<strong>CSS のグラデーションと半透明の円</strong>
            だけで作った視差です。背景の図形が前景の見出しよりゆっくり動くこと
            で「奥」にあるように感じられます。素材がなくても視差は作れます。
          </p>
        </div>
      </section>

      <!--
        パターン 2: 画像背景の視差(単層)
          背景レイヤーに 1 枚の実画像(parallax-bg.jpg)を background-image で敷き、
          それを丸ごと視差で動かす。画像は装飾なので背景レイヤーは
          aria-hidden="true"。前景テキストの可読性のため、画像の上に
          半透明の暗色オーバーレイ(::after)を重ねている。
          画像は自分の画像に差し替えて使う。
      -->
      <section class="c-parallax c-parallax--b">
        <div class="c-parallax__bg" data-parallax aria-hidden="true"></div>
        <div class="c-parallax__fg">
          <h2 class="c-parallax__heading">パターン 2: 画像背景の視差</h2>
          <p class="c-parallax__lead">
            <strong>1 枚の画像</strong>を背景に敷き、それを丸ごと視差で
            ゆっくり動かすパターンです。前景のテキストは通常の速度で流れる
            ため、画像との間に視差が生まれます。
          </p>
        </div>
      </section>

      <!--
        パターン 3: 多層(マルチレイヤー)の視差
          複数のレイヤーを「別々の速度」で動かして段階的な奥行きを作る本命。
            __bg--far   : 遠景の画像(parallax-far.jpg)。移動量を小さくしてゆっくり
                          動かす = 遠くに見える。
            __layer--near: 近景の装飾図形。移動量を大きくして速く動かす
                          = 近くに見える。
          どちらも data-parallax で視差駆動するが、各レイヤーが自分の
          --parallax-shift(移動量)を持つため、別速度で動く。
          画像レイヤーは装飾なので aria-hidden="true"。画像は差し替えて使う。
      -->
      <section class="c-parallax c-parallax--c">
        <div
          class="c-parallax__bg c-parallax__bg--far"
          data-parallax
          aria-hidden="true"
        ></div>
        <div
          class="c-parallax__layer c-parallax__layer--near"
          data-parallax
          aria-hidden="true"
        >
          <span class="c-parallax__shape c-parallax__shape--4"></span>
          <span class="c-parallax__shape c-parallax__shape--5"></span>
        </div>
        <div class="c-parallax__fg">
          <h2 class="c-parallax__heading">パターン 3: 多層の視差</h2>
          <p class="c-parallax__lead">
            <strong>複数のレイヤーを違う速度で動かして</strong>奥行きを出す
            パターンです。遠景の画像はゆっくり、手前の図形は速く動きます。
            「遠いものほどゆっくり動く」ことで段階的な立体感が生まれます。
          </p>
        </div>
      </section>

      <section class="p-outro">
        <p class="p-outro__text">
          ここがページの終端です。図形だけの視差・画像背景の視差・多層の視差の
          3 パターンで、背景が前景より遅れて動いていたことを確認できたでしょうか。
          特に多層パターンでは、遠景の画像と手前の図形が別々の速度で動いて
          いました。十分なスクロール量を確保するため、各セクションは縦に長く
          取っています。
        </p>
      </section>
    </main>

    <script src="./assets/js/script.js" defer></script>
  </body>
</html>
OPEN
CSS
/* ================================================
   カスタムプロパティ
   視差(パララックス)専用のトークン名(--parallax-*)でまとめる。
   出現演出系(--fade-*)・進捗バー系(--progress-*)とは別系統のため、
   同じページに複数を同居させてもトークンが衝突しない。
   ================================================ */
:root {
  --parallax-color-text:        #2b2b2b;
  --parallax-color-text-muted:  #51596b;
  --parallax-color-bg:          #ffffff;
  --parallax-color-border:      #d9dde6;
  --parallax-color-accent:      #5c6b8a;
  --parallax-color-accent-soft: #8b97b3;

  --parallax-radius:            10px;

  /* 視差のトークン
     --parallax-shift: レイヤーが視差で動く総移動量(px)。ここ(:root)の値は
                       既定値で、各レイヤー側で modifier クラスから個別に
                       上書きできる(例: 遠景は小さく=ゆっくり、近景は
                       大きく=速く)。移動量が小さいほど「遠く」、大きいほど
                       「近く」に見える。view() 経路・JS 経路のどちらもこの値を
                       使い、両経路で見た目の振れ幅を揃える。
     --parallax-y:     JS フォールバックが書き込む現在の縦移動量(px)。
                       CSS の view() 経路では使わない(CSS が直接動かす)。 */
  --parallax-shift:             120px;
  --parallax-y:                 0px;
}

/* ================================================
   ベースリセット(デモページ用)
   ================================================ */
*,
*::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(--parallax-color-text);
  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;
}

/* ================================================
   視差レイヤー本体
   ステージ(.c-parallax)の中に背景(__bg)と前景(__fg)を重ね、
   背景だけをスクロール進捗に連動してゆっくり動かす。
   ================================================ */

/* 視差ステージ。前景と背景を重ねる枠。
   position: relative で背景を内部に絶対配置できるようにし、
   overflow: clip で背景のはみ出し(移動分)を切り取る。

   ここで overflow: hidden を使ってはいけない。hidden はスクロール
   コンテナを生成するため、内部の背景レイヤーから見た「最も近い
   スクロールポート」がページ(document)ではなくこのステージ自身に
   なってしまう。すると背景はステージ内でスクロールせず、スクロール
   連動アニメーション(要素の通過進捗を使う方式)の進捗が一定値に
   張り付き、視差がまったく動かなくなる。
   overflow: clip は hidden と同じくはみ出しを切り取るが、スクロール
   コンテナを作らないため、背景の基準スクロールポートが document に
   戻り、ページスクロールに連動して進捗が進む。
   (clip は Chrome 90+ / Firefox 81+ / Safari 16+ で利用可。) */
.c-parallax {
  position: relative;
  /* 速度差を体感できるよう、各ステージを縦に長く取る。 */
  min-height: 90vh;
  margin: 0 0 24px;
  overflow: clip;
  border-radius: var(--parallax-radius);
  border: 1px solid var(--parallax-color-border);
  background-color: var(--parallax-color-bg);
}

/* 視差で動かすレイヤー(背景 __bg / 任意の中間層 __layer 共通)。
   ステージいっぱいに広げ、上下に余白(-shift〜+shift 分)を持たせて、
   移動しても途切れないよう天地を広めに確保する。
   余白はそのレイヤーの --parallax-shift を参照するため、レイヤーごとに
   移動量が違っても、その移動量に見合った余白が確保される。
   フックは data-parallax(出現演出・進捗バーとは別系統)。 */
.c-parallax__bg,
.c-parallax__layer {
  position: absolute;
  inset-inline: 0;
  /* 移動しても隙間ができないよう、上下にそのレイヤーの shift 分の余白を確保。 */
  inset-block: calc(var(--parallax-shift) * -1) calc(var(--parallax-shift) * -1);
}

/* 背景レイヤーは最背面(z 0)。中間層(近景)はその上(z 1)。 */
.c-parallax__bg {
  z-index: 0;
}

.c-parallax__layer {
  z-index: 1;
}

/* パターン 1 の背景: 装飾図形を散らすための土台グラデーション。
   外部画像は使わず CSS だけで自己完結させる。 */
.c-parallax--a .c-parallax__bg {
  background-image: linear-gradient(
    160deg,
    var(--parallax-color-accent) 0%,
    var(--parallax-color-accent-soft) 100%
  );
}

/* パターン 2 の背景: 1 枚の画像を敷いて丸ごと視差で動かす(単層)。
   画像は装飾(aria-hidden)。cover で全面を覆い、center で中央寄せ。 */
.c-parallax--b .c-parallax__bg {
  background-image: url("../img/parallax-bg.jpg");
  background-repeat: no-repeat;
  background-position: center;
  background-size: cover;
}

/* パターン 2 の前景テキストの可読性を確保するため、画像の上に
   半透明の暗色グラデーションを重ねる(前景より下・画像より上)。 */
.c-parallax--b .c-parallax__bg::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(
    180deg,
    rgba(20, 28, 48, 0.35) 0%,
    rgba(20, 28, 48, 0.6) 100%
  );
}

/* パターン 3 の遠景レイヤー: 画像(装飾)。移動量を小さくしてゆっくり
   動かす = 遠くに見える。前景テキストの可読性のため暗色オーバーレイを重ねる。 */
.c-parallax--c .c-parallax__bg--far {
  /* 遠景は移動量を小さく=ゆっくり=遠く見える。 */
  --parallax-shift: 50px;
  background-image: url("../img/parallax-far.jpg");
  background-repeat: no-repeat;
  background-position: center;
  background-size: cover;
}

.c-parallax--c .c-parallax__bg--far::after {
  content: "";
  position: absolute;
  inset: 0;
  background-image: linear-gradient(
    180deg,
    rgba(20, 28, 48, 0.3) 0%,
    rgba(20, 28, 48, 0.55) 100%
  );
}

/* パターン 3 の近景レイヤー: 半透明の装飾図形。移動量を大きくして速く
   動かす = 近くに見える。背景は透明(図形だけを重ねる)。 */
.c-parallax--c .c-parallax__layer--near {
  /* 近景は移動量を大きく=速く=近く見える。遠景との速度差が奥行きになる。 */
  --parallax-shift: 130px;
}

/* 背景に散らす装飾図形(外部画像を使わず CSS だけで奥行きの手がかりを作る)。 */
.c-parallax__shape {
  position: absolute;
  display: block;
  border-radius: 50%;
  background-color: rgba(255, 255, 255, 0.24);
}

.c-parallax__shape--1 {
  inset-block-start: 12%;
  inset-inline-start: 8%;
  width: 160px;
  height: 160px;
}

.c-parallax__shape--2 {
  inset-block-start: 55%;
  inset-inline-end: 12%;
  width: 220px;
  height: 220px;
  background-color: rgba(255, 255, 255, 0.16);
}

.c-parallax__shape--4 {
  inset-block-start: 18%;
  inset-inline-end: 18%;
  width: 140px;
  height: 140px;
}

.c-parallax__shape--5 {
  inset-block-start: 60%;
  inset-inline-start: 14%;
  width: 200px;
  height: 200px;
  background-color: rgba(255, 255, 255, 0.16);
}

/* 前景コンテンツ。背景の上(z-index 上)に通常フローで重ねる。
   前景は視差で動かさず、通常のスクロール速度で流れる。
   この前景と背景の速度差が「視差」として知覚される。 */
.c-parallax__fg {
  position: relative;
  z-index: 1;
  max-width: 640px;
  margin-inline: auto;
  padding: 64px 24px;
}

.c-parallax__heading {
  margin: 0 0 12px;
  font-size: 22px;
  font-weight: 700;
  color: var(--parallax-color-bg);
  text-shadow: 0 1px 8px rgba(35, 48, 77, 0.45);
}

.c-parallax__lead {
  margin: 0;
  color: rgba(255, 255, 255, 0.92);
  text-shadow: 0 1px 6px rgba(35, 48, 77, 0.4);
}

/* ------------------------------------------------
   第一実装: CSS だけで視差を成立させる(animation-timeline: view())
   対応ブラウザ(Chrome / Edge 系)では、JS を一切使わずに背景が
   スクロールに連動して動く。
   view() は対象要素(背景レイヤー)がビューポートを通過する進捗を
   0%→100% のタイムラインとして割り当てる。slide キーフレームを
   貼ることで、要素が画面下から入って上へ抜けるまでの間に、背景が
   下方向(+shift)から上方向(-shift)へゆっくり動く。前景は動かない
   ため、両者の速度差が視差になる。

   補足: CSS のみで似た効果を出す手段として background-attachment: fixed
   もあるが、モバイル(iOS Safari 等)で非対応・ガクつきの難があるため、
   本実装では採用しない。視差は view() +(フォールバックの)rAF
   translateY の両建てで成立させる。
   ------------------------------------------------ */
@keyframes c-parallax-slide {
  from {
    transform: translateY(var(--parallax-shift));
  }
  to {
    transform: translateY(calc(var(--parallax-shift) * -1));
  }
}

@supports (animation-timeline: view()) {
  .c-parallax__bg[data-parallax],
  .c-parallax__layer[data-parallax] {
    animation: c-parallax-slide linear;
    /* レイヤー自身がビューポートを通過する進捗をタイムラインに使う。 */
    animation-timeline: view();
    /* スクロールタイムラインでは duration はタイムラインの全長に
       マッピングされるため秒数は意味を持たないが、ショートハンドの
       省略時挙動のばらつきを避けるため明示しておく。 */
    animation-duration: auto;
  }
}

/* ------------------------------------------------
   JS フォールバック: animation-timeline 非対応ブラウザ向け
   (Safari / Firefox 等)
   非対応環境では上の @supports ブロックが無効化され、背景は静止する。
   JS が各背景レイヤーの視差量を計算し、要素ごとに --parallax-y(px)を
   更新する。CSS 側はそれを translateY に反映する。
   ------------------------------------------------ */
@supports not (animation-timeline: view()) {
  .c-parallax__bg[data-parallax],
  .c-parallax__layer[data-parallax] {
    transform: translateY(var(--parallax-y, 0px));
  }
}

/* ================================================
   prefers-reduced-motion: 動きを減らす設定への配慮
   視差(パララックス)は装飾的な演出であり、画面酔いを誘発しやすい。
   進捗バーのような「情報提示」ではないため、reduce 時は速度差を
   完全に止め、背景を固定して通常フロー表示にする。
   - CSS 経路(view()): animation を none にして視差アニメを止める。
   - JS 経路: 念のため transform も none に固定する(JS 側でも更新を
     止めるが、CSS でも二重に固定して取りこぼしを防ぐ)。
   両経路とも translateY を初期位置に固定し、視差が無効になる。
   ================================================ */
@media (prefers-reduced-motion: reduce) {
  .c-parallax__bg[data-parallax],
  .c-parallax__layer[data-parallax] {
    animation: none;
    transform: none;
  }
}

/* ================================================
   l-main / p-intro / p-outro: デモページのレイアウト
   ================================================ */
.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(--parallax-color-text-muted);
}

.p-outro {
  padding: 32px 24px;
  background-color: var(--parallax-color-bg);
  border: 1px solid var(--parallax-color-border);
  border-radius: var(--parallax-radius);
}

.p-outro__text {
  margin: 0;
  color: var(--parallax-color-text-muted);
}

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

  .c-parallax__heading {
    font-size: 28px;
  }
}
OPEN
JavaScript
/**
 * 背景レイヤーと前景コンテンツにスクロール速度の差をつけて、奥行き(視差)を
 * 感じさせる実装。
 *
 * 設計方針:
 * - 第一実装は CSS の animation-timeline: view()。対応ブラウザでは
 *   この JS は何もしない(CSS だけで背景がスクロールに連動する)。
 * - この JS は animation-timeline 非対応ブラウザ(Safari / Firefox 等)
 *   向けの「フォールバック」専任。各視差レイヤーの視差量を計算し、要素ごとの
 *   CSS カスタムプロパティ --parallax-y(px)へ書き込む。CSS 側がそれを
 *   translateY に反映する。
 *
 * レイヤーごとの速度差(多層パララックス対応):
 * - 各レイヤーの移動量(総移動量)は、その要素自身の --parallax-shift から
 *   読み取る。要素に指定がなければ :root の既定値、それも取れなければ 120px。
 *   これにより「遠景はゆっくり・近景は速く」のように、レイヤーごとに別速度で
 *   動かせる(CSS の view() 経路と同じ振れ幅になる)。
 *
 * 二重駆動の防止:
 * - CSS.supports('animation-timeline', 'view()') が true の環境では、
 *   CSS が視差を担うため scroll リスナーを一切張らない。
 *   こうして「CSS と JS が同時に背景を動かす」二重駆動を防ぐ。
 *   判定キー(animation-timeline: view())は CSS の @supports と完全一致。
 *
 * アクセシビリティ:
 * - prefers-reduced-motion: reduce のときは視差を完全に無効化する。
 *   視差は装飾的な演出で画面酔いを誘発しやすいため、追従を止めて背景を
 *   固定する。JS 経路ではリスナーを張らず、--parallax-y も 0 のままにする
 *   (CSS 側でも transform: none に固定して二重に止める)。
 *
 * パフォーマンス:
 * - scroll イベントは高頻度で発火するため、毎回計算せず
 *   requestAnimationFrame でフレーム同期に間引きする(rAF スロットル)。
 *   ticking フラグで多重 rAF 予約を防ぎ、1 フレームに 1 回だけ更新する。
 * - scroll / resize リスナーは passive: true で、スクロールをブロック
 *   しないことを明示する。
 *
 * プログレッシブエンハンスメント:
 * - 背景レイヤーは対応・非対応・JS 無効のいずれでも常に表示される
 *   (視差が効かないだけで、レイアウトもコンテンツも壊れない)。
 */

(function () {
  "use strict";

  const layers = document.querySelectorAll("[data-parallax]");
  if (!layers.length) return;

  // 視差は装飾。OS の「動きを減らす」設定が有効なら、視差を完全に無効化し
  // リスナーを張らない(レイヤーは CSS 側で transform: none に固定される)。
  const reduceMotion =
    typeof window.matchMedia === "function" &&
    window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  if (reduceMotion) return;

  // CSS の animation-timeline: view() が使える環境では CSS に任せ、
  // JS は何もしない(二重駆動の防止)。判定キーは CSS の @supports と一致。
  // CSS.supports 自体が無い極めて古い環境では JS フォールバックへ進む。
  const cssDriven =
    typeof window.CSS !== "undefined" &&
    typeof window.CSS.supports === "function" &&
    window.CSS.supports("animation-timeline", "view()");

  if (cssDriven) return;

  // ---- ここから JS フォールバック(animation-timeline 非対応環境)----

  const root = document.documentElement;

  // 既定の総移動量(px)。:root の --parallax-shift から読み取り、要素側に
  // 個別指定がないレイヤーのフォールバックに使う。取れない/不正なら 120px。
  function readRootShift() {
    const raw = getComputedStyle(root)
      .getPropertyValue("--parallax-shift")
      .trim();
    const value = parseFloat(raw);
    return Number.isFinite(value) && value >= 0 ? value : 120;
  }

  // 個々のレイヤーの総移動量(px)。そのレイヤー自身の computed style から
  // --parallax-shift を読み、レイヤーごとに別の移動量(=速度)を許す。
  // 取れない/不正なら :root の既定値へフォールバックする。
  function readLayerShift(el, rootShift) {
    const raw = getComputedStyle(el).getPropertyValue("--parallax-shift").trim();
    const value = parseFloat(raw);
    return Number.isFinite(value) && value >= 0 ? value : rootShift;
  }

  let rootShift = readRootShift();

  // 多重 rAF 予約を防ぐフラグ。scroll が連続発火しても、
  // 1 フレームにつき更新は 1 回だけにする。
  let ticking = false;

  /**
   * 各視差レイヤーの視差量を計算し、要素ごとに --parallax-y(px)を更新する。
   *
   * 各レイヤーがビューポートを通過する進捗(0〜1)を求め、それを
   * +shift(画面下から入る瞬間)→ -shift(画面上へ抜ける瞬間)へ
   * 線形にマッピングする。shift はレイヤーごとに個別に読むため、遠景・近景
   * など複数レイヤーをそれぞれ別の速度で動かせる。前景は動かないため、
   * このレイヤーの移動が視差になる。
   */
  function update() {
    const viewportH = window.innerHeight || root.clientHeight || 0;

    for (let i = 0; i < layers.length; i++) {
      const layer = layers[i];
      const rect = layer.getBoundingClientRect();

      // このレイヤー固有の総移動量(速度)。要素指定がなければ root 既定へ。
      const shift = readLayerShift(layer, rootShift);

      // 要素がビューポートを通過する進捗 0〜1。
      // 要素の上端が画面下端にあるとき 0、要素の下端が画面上端に
      // 抜けるとき 1。分母 0 ガードつき。
      const span = viewportH + rect.height;
      const progress = span > 0 ? (viewportH - rect.top) / span : 0;

      // 0〜1 にクランプ(画面外で過剰な移動量にならないように)。
      const clamped = progress < 0 ? 0 : progress > 1 ? 1 : progress;

      // 進捗 0→1 を +shift → -shift にマッピング。
      const y = shift - clamped * shift * 2;
      layer.style.setProperty("--parallax-y", y.toFixed(2) + "px");
    }

    ticking = false;
  }

  /**
   * scroll / resize ハンドラ。計算自体は rAF に遅延させ、
   * フレーム同期で 1 回だけ update を走らせる(rAF スロットル)。
   */
  function onScroll() {
    if (ticking) return;
    ticking = true;
    window.requestAnimationFrame(update);
  }

  // resize ではビューポート高とトークン値が変わり得るため、root 既定の
  // shift を読み直す(各レイヤー固有値は update 内で毎回読むため不要)。
  function onResize() {
    rootShift = readRootShift();
    onScroll();
  }

  // 初期表示時にも一度反映しておく(リロード時にスクロール位置が
  // 途中だった場合に、最初から正しい視差位置を見せる)。
  update();

  window.addEventListener("scroll", onScroll, { passive: true });
  window.addEventListener("resize", onResize, { passive: true });
})();
OPEN

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

  • 図形パターンの背景色: --parallax-color-accent / --parallax-color-accent-soft
  • 視差の移動量(強さ): --parallax-shift(レイヤーごとに上書きすると速度差が出る)
  • 背景画像: .c-parallax--b .c-parallax__bg / .c-parallax--c .c-parallax__bg--farurl(...)(自分の画像に差し替える)
  • ステージの高さ: .c-parallaxmin-height
  • 背景の装飾図形: .c-parallax__shape の位置・サイズ

よくある質問

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

動きます。対応ブラウザではCSSだけでレイヤーが動き、未対応ブラウザでは付属のJavaScriptがスクロールから移動量を計算してレイヤーを動かします。@supports でCSSの実装を切り替え、JS側も対応状況を判定するため、対応・非対応のどちらでも同じ視差が機能します。

スマホでパララックスが効きません。background-attachment: fixed が原因ですか?

その可能性が高いです。background-attachment: fixed はモバイル(iOS Safari等)で非対応・ガクつきの難があります。このスニペットでは fixed を使わず、animation-timeline: view()transform: translateY() で視差を作るため、モバイルでも動きます。

動きを減らすOS設定が有効なとき、パララックスはどうなりますか?

視差を完全に止めて、レイヤーを固定した通常表示になります。パララックスは装飾的な演出で画面酔いを誘発しやすいため、読了率を示す進捗バーのような情報提示UIとは違い、止めても情報は失われません。このスニペットでは動きを減らす設定で視差を無効化しています。

レイヤーごとに動く速さを変えるには?

レイヤーごとに --parallax-shift(移動量)を変えます。移動量が小さいほどゆっくり動いて「遠く」に、大きいほど速く動いて「近く」に見えます。遠景を小さく・近景を大きくすると、現実の見え方に近い多層の奥行きになります。CSS経路とJS経路の両方がレイヤー単位でこの値を読むため、設定はこの1つで両対応します。

まとめ

CSSの animation-timeline: view() と従来のJSで両対応するパララックス(視差スクロール)を紹介しました。要点は次の通りです。

  • 同じ仕組みで、図形だけ・実画像・多層(速度差)の3パターンを作り分けられる
  • 多層は、レイヤーごとに --parallax-shift を変えて速度差を作る(遠いものほどゆっくり)
  • 対応ブラウザはCSSの animation-timeline: view() だけで動く(JS不要)
  • 未対応ブラウザは scroll イベント+requestAnimationFrame のJSで同じ移動量を計算する
  • @supports でCSSを排他に切り替え、JSは CSS.supports 判定で二重駆動を防ぐ
  • 視差は装飾なので、動きを減らす設定では完全に止める
  • ステージのはみ出しは overflow: hidden ではなく overflow: clip で切り取る

まずはコピペして動かし、対応ブラウザではJSなしで動くこと、--parallax-shift で視差の強さや速度差を変えられることを確認してみてください。実画像のパターンは、自分の画像に差し替えて使ってください。

【関連記事】