Web Animations Medium
Parallax Layers
Layered shapes respond to scroll progress with CSS variables and a small JS fallback.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
/* Parallax Layers */
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #0b1020;
color: #eef2ff;
font: 15px/1.55 system-ui, -apple-system, Segoe UI, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
.demo {
width: min(920px, 100%);
margin: auto;
background: #121a2d;
border: 1px solid #263555;
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0006;
}
.head h1 { margin: 0 0 .25rem; font-size: clamp(1.25rem, 3vw, 1.7rem); }
.muted { color: #a8b5d1; margin: 0 0 1.25rem; }
/* Stage ------------------------------------------------------------------ */
.stage {
position: relative;
height: clamp(360px, 60vh, 520px);
border-radius: 18px;
overflow: hidden;
background: linear-gradient(#101a33 0%, #16233f 55%, #1d2c4c 100%);
border: 1px solid #2b3d62;
}
.scene { position: absolute; inset: 0; }
.layer {
position: absolute;
inset: -8% -6%;
will-change: transform;
transform: translate3d(0, 0, 0);
}
.layer svg { width: 100%; height: 100%; display: block; }
.fog {
position: absolute;
inset: auto 0 0 0;
height: 45%;
background: linear-gradient(transparent, #0b1020cc 70%, #0b1020);
pointer-events: none;
}
/* Scroller --------------------------------------------------------------- */
.scroller {
position: absolute;
inset: 0;
overflow-y: auto;
scroll-behavior: smooth;
padding: 12% 1.25rem 22%;
display: grid;
gap: clamp(3rem, 12vh, 7rem);
scrollbar-width: thin;
scrollbar-color: #4a63a0 transparent;
}
.scroller:focus-visible { outline: 2px solid #78a9ff; outline-offset: -2px; }
.panel {
max-width: 34ch;
padding: 1rem 1.1rem;
border-radius: 14px;
background: #0d152999;
border: 1px solid #ffffff14;
backdrop-filter: blur(6px);
}
.panel:nth-child(even) { margin-left: auto; }
.panel h2 { margin: .15rem 0 .4rem; font-size: 1.05rem; }
.panel p { margin: 0; color: #c3cfe8; font-size: .92rem; }
.eyebrow {
margin: 0;
font-size: .7rem;
letter-spacing: .14em;
text-transform: uppercase;
color: #8fb4ff;
}
/* HUD -------------------------------------------------------------------- */
.hud {
position: absolute;
left: 0; right: 0; bottom: 0;
display: flex;
align-items: center;
gap: .65rem;
padding: .6rem .9rem;
background: #0b1020cc;
border-top: 1px solid #ffffff12;
font-size: .78rem;
}
.hud label { color: #9eb1d4; }
.hud progress {
flex: 1;
height: .4rem;
appearance: none;
border: 0;
border-radius: 99px;
background: #263555;
overflow: hidden;
}
.hud progress::-webkit-progress-bar { background: #263555; }
.hud progress::-webkit-progress-value { background: #78a9ff; }
.hud progress::-moz-progress-bar { background: #78a9ff; }
.hud output { font-variant-numeric: tabular-nums; min-width: 3.2ch; text-align: right; }
@media (max-width: 600px) {
.demo { border-radius: 16px; }
.panel { max-width: none; }
}
@media (prefers-reduced-motion: reduce) {
.scroller { scroll-behavior: auto; }
.layer { transition: none !important; }
}// Parallax Layers — scroll progress drives per-layer translate + scale.
(function () {
const scroller = document.getElementById("scroller");
const stage = document.getElementById("stage");
const bar = document.getElementById("progress");
const pct = document.getElementById("pct");
if (!scroller || !stage) return;
const layers = Array.from(stage.querySelectorAll(".layer")).map((el) => ({
el,
depth: parseFloat(el.dataset.depth) || 0,
scale: parseFloat(el.dataset.scale) || 0,
}));
const reduced = matchMedia("(prefers-reduced-motion: reduce)");
let ticking = false;
let progress = 0;
function measure() {
const max = scroller.scrollHeight - scroller.clientHeight;
progress = max > 0 ? scroller.scrollTop / max : 0;
if (progress < 0) progress = 0;
if (progress > 1) progress = 1;
}
function render() {
ticking = false;
const travel = stage.clientHeight * 0.55; // px of movement at depth 1
const amount = reduced.matches ? 0 : progress;
for (const l of layers) {
const y = -amount * travel * l.depth;
const s = 1 + amount * l.scale;
l.el.style.transform =
"translate3d(0," + y.toFixed(2) + "px,0) scale(" + s.toFixed(4) + ")";
}
const p = Math.round(progress * 100);
if (bar) bar.value = p;
if (pct) pct.textContent = p + "%";
stage.style.setProperty("--progress", progress.toFixed(4));
}
function onScroll() {
measure();
if (!ticking) {
ticking = true;
requestAnimationFrame(render);
}
}
scroller.addEventListener("scroll", onScroll, { passive: true });
addEventListener("resize", onScroll, { passive: true });
if (reduced.addEventListener) reduced.addEventListener("change", onScroll);
// Keyboard fallback: the scroller is focusable, drive it explicitly so the
// behavior is identical without a wheel or touch surface.
scroller.addEventListener("keydown", (e) => {
const page = scroller.clientHeight * 0.9;
const step = 80;
const map = {
ArrowDown: step,
ArrowUp: -step,
PageDown: page,
PageUp: -page,
};
if (e.key in map) {
scroller.scrollTop += map[e.key];
} else if (e.key === "Home") {
scroller.scrollTop = 0;
} else if (e.key === "End") {
scroller.scrollTop = scroller.scrollHeight;
} else {
return;
}
e.preventDefault();
});
onScroll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Parallax Layers</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Parallax Layers">
<header class="head">
<h1>Parallax layers</h1>
<p class="muted">Scroll inside the scene. Each layer translates by its own depth factor.</p>
</header>
<div class="stage" id="stage">
<div class="scene" aria-hidden="true">
<div class="layer" data-depth="0.05" data-scale="0.02">
<svg viewBox="0 0 400 220" preserveAspectRatio="none">
<circle cx="312" cy="54" r="30" fill="#f3d7a5" />
<circle cx="60" cy="40" r="2" fill="#dbe6ff" />
<circle cx="140" cy="72" r="1.6" fill="#dbe6ff" />
<circle cx="220" cy="34" r="1.8" fill="#dbe6ff" />
</svg>
</div>
<div class="layer" data-depth="0.18" data-scale="0.04">
<svg viewBox="0 0 400 220" preserveAspectRatio="none">
<path d="M0 220 L70 96 L145 220 Z" fill="#26304a" />
<path d="M110 220 L200 62 L292 220 Z" fill="#2d3859" />
<path d="M250 220 L332 104 L400 220 Z" fill="#26304a" />
</svg>
</div>
<div class="layer" data-depth="0.44" data-scale="0.07">
<svg viewBox="0 0 400 220" preserveAspectRatio="none">
<path d="M-10 220 L60 126 L134 220 Z" fill="#3b4f6f" />
<path d="M100 220 L192 110 L286 220 Z" fill="#455c80" />
<path d="M244 220 L322 136 L410 220 Z" fill="#3b4f6f" />
</svg>
</div>
<div class="layer" data-depth="0.8" data-scale="0.12">
<svg viewBox="0 0 400 220" preserveAspectRatio="none">
<path d="M-30 220 L60 158 L155 220 Z" fill="#5b7aa3" />
<path d="M120 220 L235 146 L350 220 Z" fill="#6d8dba" />
</svg>
</div>
<div class="fog"></div>
</div>
<div class="scroller" id="scroller" tabindex="0" role="region"
aria-label="Parallax scene — scroll or use arrow keys to move the layers">
<section class="panel">
<p class="eyebrow">Depth 0.05</p>
<h2>The sky barely drifts</h2>
<p>Distant layers move least, which is what sells the illusion of depth.</p>
</section>
<section class="panel">
<p class="eyebrow">Depth 0.18</p>
<h2>Far ridge</h2>
<p>Translation is proportional to scroll progress times the layer depth.</p>
</section>
<section class="panel">
<p class="eyebrow">Depth 0.44</p>
<h2>Mid ridge</h2>
<p>Each layer also scales slightly so the parallax reads as a dolly move.</p>
</section>
<section class="panel">
<p class="eyebrow">Depth 0.80</p>
<h2>Foreground</h2>
<p>The nearest layer sweeps past fastest and blurs at the edges of travel.</p>
</section>
<section class="panel">
<p class="eyebrow">Done</p>
<h2>Progress 100%</h2>
<p>Keyboard users can drive the same scroll with arrows, Page keys, Home and End.</p>
</section>
</div>
<div class="hud">
<label for="progress">Scroll progress</label>
<progress id="progress" max="100" value="0"></progress>
<output id="pct" for="progress">0%</output>
</div>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useEffect, useState } from "react";
export function ScrollParallaxLayers() {
const [progress, setProgress] = useState(0);
useEffect(() => {
const onScroll = () =>
setProgress(
Math.min(
100,
Math.round((scrollY / Math.max(1, document.body.scrollHeight - innerHeight)) * 100)
)
);
addEventListener("scroll", onScroll, { passive: true });
return () => removeEventListener("scroll", onScroll);
}, []);
return (
<section className="demo">
<h2>Parallax Layers</h2>
<div className="progress">
<i style={{ width: `${progress}%` }} />
</div>
<div style={{ minHeight: 420, paddingTop: 120 }}>
<article className="card">Scroll-linked content · {progress}%</article>
</div>
</section>
);
}Parallax Layers
Layered shapes respond to scroll progress with CSS variables and a small JS fallback.
Support notes
The CSS uses animation-timeline where available and a small JavaScript fallback where it is not. Keep the fallback because scroll-linked CSS support varies by browser.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.