この記事では、背景と前景に速度差をつけて奥行きを出すパララックス(視差スクロール)を紹介します。図形だけで作る方法も、実画像を使う方法も、複数レイヤーを別速度で動かす多層の方法も、同じ仕組みで作れます。CSSだけで書ける新しい方法(animation-timeline: view())と、全ブラウザで動く従来のJS(scroll イベント+requestAnimationFrame)を両方使い、対応・非対応のどちらでも動く形にします。background-attachment: fixed がスマホで効かない問題も避けられます。コピペで動くHTML・CSS・JSとカスタマイズのポイントを解説します。
- 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を見てみましょう。前景と背景を重ねるステージの中に、ゆっくり動かす背景レイヤーと、通常速度で流れる前景コンテンツを置く構造です。
<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は次の通りです(デモ用の装飾は完成コードにまとめています)。
@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)で共通のスタイルです。
.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経路の両方でこの値を使い、見た目の振れ幅を揃えます。
/* 遠景は移動量を小さく=ゆっくり=遠く見える。 */
.c-parallax--c .c-parallax__bg--far {
--parallax-shift: 50px;
}
/* 近景は移動量を大きく=速く=近く見える。両者の差が奥行きになる。 */
.c-parallax--c .c-parallax__layer--near {
--parallax-shift: 130px;
}移動量が小さいほどゆっくり動いて「遠く」に、大きいほど速く動いて「近く」に見えます。これが多層パララックスの正体です。
画像背景は前景の可読性をオーバーレイで確保する
画像を背景に敷くパターンでは、画像の明るさによって前景テキストが読みづらくなります。そこで画像レイヤーの上に半透明の暗色グラデーションを ::after で重ね、前景テキストとのコントラストを確保します。
.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 で実装を排他に切り替えます。背景レイヤーと中間レイヤーの両方を対象にします。
@supports not (animation-timeline: view()) {
.c-parallax__bg[data-parallax],
.c-parallax__layer[data-parallax] {
transform: translateY(var(--parallax-y, 0px));
}
}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 });
})();レイヤーごとに移動量(速度)を読む
この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 が走り終えると ticking を false に戻します。これにより、連続発火しても1フレームにつき更新は1回だけになります。リスナーは passive: true で登録し、スクロール自体をブロックしないようにしています。
CSSとJSの二重駆動を防ぐ
CSSとJSの両方が同時にレイヤーを動かすと、二重駆動になってしまいます。これを防ぐため、JSの冒頭で CSS.supports('animation-timeline', 'view()') を判定し、CSSだけで動く環境では scroll リスナーを一切張らずに早期 return します。
結果として、CSSが視差を担う環境ではJSは何もせず、JSが動くのは未対応環境だけ、という役割分担になります。なお各レイヤーは、対応・非対応・JS無効のいずれの場合でも常に表示されます。視差が効かないだけで、レイアウトやコンテンツは壊れません。
動きを減らす設定への配慮(prefers-reduced-motion)
視差は「どこまで読んだか」を示す情報提示ではなく、装飾的な演出です。レイヤーが前景と速度差で動くため、画面酔いを誘発しやすい動きでもあります。そのため、動きを減らす設定が有効なときは視差を完全に無効化し、レイヤーを固定して通常フロー表示にします。止めても情報は失われません。
@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> のインラインスクリプトを含む全体です。このまま貼り付ければ動作する状態です。
<!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>/* ================================================
カスタムプロパティ
視差(パララックス)専用のトークン名(--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;
}
}/**
* 背景レイヤーと前景コンテンツにスクロール速度の差をつけて、奥行き(視差)を
* 感じさせる実装。
*
* 設計方針:
* - 第一実装は 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 });
})();カスタマイズしたい場面でよく触るのは次のポイントです。
- 図形パターンの背景色:
--parallax-color-accent/--parallax-color-accent-soft - 視差の移動量(強さ):
--parallax-shift(レイヤーごとに上書きすると速度差が出る) - 背景画像:
.c-parallax--b .c-parallax__bg/.c-parallax--c .c-parallax__bg--farのurl(...)(自分の画像に差し替える) - ステージの高さ:
.c-parallaxのmin-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 で視差の強さや速度差を変えられることを確認してみてください。実画像のパターンは、自分の画像に差し替えて使ってください。
【関連記事】
