Parallax Depth Scene
A layered diorama where background, mid and foreground planes drift at different rates from pointer or scroll, each with depth-cued blur, scale and shadow for a tactile 3D feel.
MCP
Code
:root {
--bg: #0c0d10;
--surface: #15161b;
--surface-2: #1d1f27;
--ink: #e9eaf0;
--muted: #9a9cab;
--accent: #8b5cf6;
--accent-2: #22d3ee;
--ok: #34d399;
--warn: #fbbf24;
--danger: #f87171;
--line: rgba(255, 255, 255, 0.10);
--line-2: rgba(255, 255, 255, 0.18);
--r-sm: 8px;
--r-md: 12px;
--r-lg: 16px;
}
* {
box-sizing: border-box;
}
html {
-webkit-text-size-adjust: 100%;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1100px 620px at 82% -8%, rgba(139, 92, 246, 0.14), transparent 60%),
radial-gradient(900px 560px at 10% 108%, rgba(34, 211, 238, 0.10), transparent 62%),
var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, "Segoe UI", sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
display: flex;
justify-content: center;
padding: clamp(16px, 4vw, 48px);
}
.wrap {
width: 100%;
max-width: 920px;
display: flex;
flex-direction: column;
gap: 22px;
}
/* ---------- header ---------- */
.head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
flex-wrap: wrap;
}
.eyebrow {
margin: 0 0 8px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--accent-2);
}
.head h1 {
margin: 0 0 8px;
font-size: clamp(24px, 4.4vw, 34px);
font-weight: 700;
letter-spacing: -0.02em;
}
.sub {
margin: 0;
max-width: 54ch;
color: var(--muted);
font-size: 14.5px;
}
.mode {
display: inline-flex;
padding: 4px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
gap: 2px;
}
.mode-btn {
appearance: none;
border: 0;
cursor: pointer;
font: inherit;
font-weight: 600;
font-size: 13.5px;
color: var(--muted);
padding: 8px 18px;
border-radius: 999px;
background: transparent;
transition: color 0.18s ease, background 0.18s ease;
}
.mode-btn:hover {
color: var(--ink);
}
.mode-btn.is-active {
color: #fff;
background: linear-gradient(135deg, var(--accent), #6d3ff0);
box-shadow: 0 4px 16px rgba(139, 92, 246, 0.35);
}
/* ---------- stage ---------- */
.stage-shell {
border-radius: var(--r-lg);
padding: 1px;
background: linear-gradient(180deg, var(--line-2), transparent 45%);
}
.stage {
position: relative;
aspect-ratio: 16 / 9;
border-radius: calc(var(--r-lg) - 1px);
overflow: hidden;
background: linear-gradient(180deg, #0a1230 0%, #14103a 46%, #241a3f 100%);
cursor: crosshair;
outline: none;
isolation: isolate;
}
.stage:focus-visible {
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.55);
}
.scene {
position: absolute;
inset: -8%;
transform-style: preserve-3d;
perspective: 900px;
}
.layer {
position: absolute;
inset: 0;
will-change: transform, filter;
transform: translate3d(0, 0, 0);
}
/* sky */
.layer--sky {
background:
radial-gradient(120% 90% at 50% 118%, rgba(34, 211, 238, 0.08), transparent 55%);
}
.sky-glow {
position: absolute;
inset: 0;
background:
radial-gradient(80% 60% at 74% 26%, rgba(139, 92, 246, 0.30), transparent 60%);
}
.star {
position: absolute;
left: var(--x);
top: var(--y);
width: var(--s);
height: var(--s);
border-radius: 50%;
background: #fff;
opacity: 0.85;
box-shadow: 0 0 6px rgba(255, 255, 255, 0.7);
animation: twinkle 3.4s ease-in-out infinite;
}
.star:nth-child(3) { animation-delay: 0.6s; }
.star:nth-child(4) { animation-delay: 1.2s; }
.star:nth-child(5) { animation-delay: 1.8s; }
.star:nth-child(6) { animation-delay: 2.4s; }
@keyframes twinkle {
0%, 100% { opacity: 0.25; }
50% { opacity: 0.95; }
}
/* sun / moon disc */
.sun {
position: absolute;
top: 20%;
left: 70%;
width: 84px;
height: 84px;
border-radius: 50%;
background: radial-gradient(circle at 38% 34%, #fff6d8, #ffd27a 46%, #f6a94b 78%);
box-shadow:
0 0 60px 18px rgba(255, 196, 110, 0.45),
0 0 120px 40px rgba(255, 150, 80, 0.18);
}
/* ridges */
.ridge {
position: absolute;
inset: auto 0 0 0;
width: 100%;
height: 100%;
display: block;
}
.layer--hills-far .ridge path {
fill: #2a2350;
}
.layer--hills-mid .ridge path {
fill: #201a3c;
}
/* monolith */
.monolith {
position: absolute;
bottom: 20%;
left: 50%;
width: 46px;
height: 190px;
transform: translateX(-50%) skewX(-2deg);
background: linear-gradient(180deg, #4a3d86, #16112c);
border-radius: 6px 6px 2px 2px;
box-shadow:
inset 8px 0 14px rgba(255, 255, 255, 0.12),
inset -10px 0 18px rgba(0, 0, 0, 0.5),
0 24px 40px -14px rgba(0, 0, 0, 0.7);
}
.monolith::after {
content: "";
position: absolute;
left: 10px;
top: 14px;
width: 6px;
height: 60%;
border-radius: 4px;
background: linear-gradient(180deg, rgba(34, 211, 238, 0.9), rgba(139, 92, 246, 0.5));
box-shadow: 0 0 14px rgba(34, 211, 238, 0.7);
}
/* trees */
.tree {
position: absolute;
bottom: 12%;
left: var(--tx);
width: 0;
height: 0;
border-left: 22px solid transparent;
border-right: 22px solid transparent;
border-bottom: var(--th) solid #10152a;
filter: drop-shadow(0 10px 14px rgba(0, 0, 0, 0.5));
}
.tree::after {
content: "";
position: absolute;
left: -3px;
top: calc(var(--th) - 2px);
width: 6px;
height: 18px;
background: #0a0e1c;
transform: translateX(-50%);
}
/* foreground grass band */
.grass {
position: absolute;
inset: auto 0 0 0;
height: 20%;
background:
linear-gradient(180deg, transparent, #05060d 72%),
repeating-linear-gradient(90deg, #0b0f1e 0 14px, #0e1424 14px 28px);
border-top: 2px solid rgba(34, 211, 238, 0.14);
}
/* inner vignette frame — stays fixed */
.frame {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
box-shadow:
inset 0 0 90px 30px rgba(0, 0, 0, 0.55),
inset 0 0 0 1px rgba(255, 255, 255, 0.04);
border-radius: inherit;
}
.rm-note {
position: absolute;
left: 12px;
bottom: 12px;
z-index: 6;
margin: 0;
max-width: 60%;
padding: 8px 12px;
font-size: 12.5px;
color: var(--ink);
background: rgba(12, 13, 16, 0.72);
border: 1px solid var(--line);
border-radius: var(--r-sm);
backdrop-filter: blur(6px);
}
/* ---------- controls ---------- */
.controls {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)) auto;
gap: 16px 22px;
padding: 18px 20px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-lg);
}
.control {
min-width: 0;
}
.control--toggles {
grid-column: 3;
grid-row: 1 / span 2;
display: flex;
flex-direction: column;
justify-content: center;
gap: 10px;
align-self: stretch;
}
.control-label {
display: flex;
align-items: baseline;
justify-content: space-between;
margin-bottom: 8px;
font-size: 13px;
font-weight: 500;
}
.control-label label {
color: var(--ink);
}
.control-label output {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
color: var(--accent-2);
}
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent) 0%, var(--accent) var(--fill, 50%), var(--surface-2) var(--fill, 50%));
outline: none;
cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--accent);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
border: 3px solid var(--accent);
cursor: pointer;
}
input[type="range"]:focus-visible {
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.45);
}
.toggle,
.ghost {
appearance: none;
cursor: pointer;
font: inherit;
font-weight: 600;
font-size: 13px;
color: var(--ink);
border-radius: var(--r-sm);
border: 1px solid var(--line-2);
background: var(--surface-2);
padding: 10px 14px;
transition: border-color 0.18s ease, background 0.18s ease, color 0.18s ease;
}
.toggle {
display: inline-flex;
align-items: center;
gap: 9px;
}
.toggle .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--muted);
box-shadow: 0 0 0 3px rgba(255, 255, 255, 0.05);
transition: background 0.18s ease, box-shadow 0.18s ease;
}
.toggle[aria-pressed="true"] {
border-color: var(--ok);
color: var(--ok);
}
.toggle[aria-pressed="true"] .dot {
background: var(--ok);
box-shadow: 0 0 10px rgba(52, 211, 153, 0.6);
}
.ghost:hover,
.toggle:hover {
border-color: var(--accent-2);
}
.ghost:focus-visible,
.toggle:focus-visible,
.mode-btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.45);
}
/* ---------- footer ---------- */
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
}
.readout {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.chip {
font-size: 12.5px;
color: var(--muted);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 999px;
padding: 5px 12px;
}
.chip b {
color: var(--ink);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-weight: 500;
}
.tech {
margin: 0;
flex: 1;
min-width: 240px;
text-align: right;
font-size: 12.5px;
color: var(--muted);
}
.tech code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 11.5px;
color: var(--accent-2);
background: rgba(34, 211, 238, 0.08);
padding: 1px 5px;
border-radius: 5px;
}
/* ---------- responsive ---------- */
@media (max-width: 520px) {
.head {
flex-direction: column;
}
.mode {
align-self: flex-start;
}
.controls {
grid-template-columns: 1fr;
}
.control--toggles {
grid-column: 1;
grid-row: auto;
flex-direction: row;
}
.control--toggles > * {
flex: 1;
}
.tech {
text-align: left;
}
}
@media (prefers-reduced-motion: reduce) {
.star {
animation: none;
}
}(() => {
"use strict";
const stage = document.getElementById("stage");
const scene = document.getElementById("scene");
const layers = Array.from(scene.querySelectorAll(".layer"));
const rmNote = document.getElementById("rmNote");
const depthInput = document.getElementById("depth");
const dofInput = document.getElementById("dof");
const tiltInput = document.getElementById("tilt");
const depthOut = document.getElementById("depthOut");
const dofOut = document.getElementById("dofOut");
const tiltOut = document.getElementById("tiltOut");
const reduceBtn = document.getElementById("reduce");
const resetBtn = document.getElementById("reset");
const modeBtns = Array.from(document.querySelectorAll(".mode-btn"));
const modeVal = document.getElementById("modeVal");
const offVal = document.getElementById("offVal");
const fpsVal = document.getElementById("fpsVal");
// Precompute per-layer depth + baseline filter so we never re-read DOM in the loop.
const layerData = layers.map((el) => {
const depth = parseFloat(el.dataset.depth) || 0;
return { el, depth };
});
const maxDepth = Math.max(...layerData.map((l) => l.depth)) || 1;
const settings = {
depth: 1,
dof: 1,
tilt: 1,
mode: "pointer", // "pointer" | "scroll"
reduced: false,
};
const mql = window.matchMedia("(prefers-reduced-motion: reduce)");
// target = where input wants layers to be; current = spring-smoothed value.
const target = { x: 0, y: 0 };
const current = { x: 0, y: 0 };
let rafId = null;
let running = false;
// ---------- input handlers ----------
function setTargetFromPointer(clientX, clientY) {
const r = stage.getBoundingClientRect();
const nx = ((clientX - r.left) / r.width) * 2 - 1; // -1..1
const ny = ((clientY - r.top) / r.height) * 2 - 1;
target.x = clamp(nx, -1, 1);
target.y = clamp(ny, -1, 1);
}
function onPointerMove(e) {
if (settings.mode !== "pointer" || settings.reduced) return;
setTargetFromPointer(e.clientX, e.clientY);
}
function onPointerLeave() {
if (settings.mode !== "pointer") return;
target.x = 0;
target.y = 0;
}
function onTouchMove(e) {
if (settings.mode !== "pointer" || settings.reduced) return;
if (!e.touches || !e.touches[0]) return;
setTargetFromPointer(e.touches[0].clientX, e.touches[0].clientY);
}
function onScroll() {
if (settings.mode !== "scroll" || settings.reduced) return;
// Map the stage's position within the viewport to a -1..1 vertical field.
const r = stage.getBoundingClientRect();
const vh = window.innerHeight || document.documentElement.clientHeight;
const center = r.top + r.height / 2;
const progress = center / vh; // ~1 near bottom, ~0 near top
target.y = clamp((0.5 - progress) * 2, -1, 1);
target.x = clamp(target.y * 0.35, -1, 1); // gentle horizontal drift
}
// Device orientation gives touch users real parallax without a pointer.
function onOrient(e) {
if (settings.mode !== "pointer" || settings.reduced) return;
if (e.gamma == null || e.beta == null) return;
target.x = clamp(e.gamma / 30, -1, 1);
target.y = clamp((e.beta - 45) / 30, -1, 1);
}
// ---------- render ----------
function applyLayers(x, y) {
const dep = settings.depth;
const dof = settings.dof;
const tilt = settings.tilt;
for (let i = 0; i < layerData.length; i++) {
const { el, depth } = layerData[i];
// Nearer layers (higher depth) translate further — that's the parallax.
const shift = depth * 26 * dep;
const tx = -x * shift;
const ty = -y * shift * 0.7;
const rot = x * depth * 1.1 * tilt; // subtle z-rotation, depth-scaled
const scale = 1 + depth * 0.02 * dep;
// Far layers get blur + desaturation; near layers stay crisp.
const norm = depth / maxDepth; // 0..1
const blur = (1 - norm) * 3.2 * dof;
const sat = 0.72 + norm * 0.28;
el.style.transform =
"translate3d(" + tx.toFixed(2) + "px," + ty.toFixed(2) + "px,0) " +
"rotate(" + rot.toFixed(3) + "deg) " +
"scale(" + scale.toFixed(4) + ")";
el.style.filter = "blur(" + blur.toFixed(2) + "px) saturate(" + sat.toFixed(2) + ")";
}
}
// ---------- animation loop with spring smoothing ----------
let lastFpsT = performance.now();
let frames = 0;
function tick(now) {
// critically-damped-ish easing toward target
const k = 0.12;
current.x += (target.x - current.x) * k;
current.y += (target.y - current.y) * k;
applyLayers(current.x, current.y);
offVal.textContent = current.x.toFixed(2) + ", " + current.y.toFixed(2);
frames++;
if (now - lastFpsT >= 500) {
fpsVal.textContent = Math.round((frames * 1000) / (now - lastFpsT));
frames = 0;
lastFpsT = now;
}
rafId = requestAnimationFrame(tick);
}
function startLoop() {
if (running) return;
running = true;
lastFpsT = performance.now();
frames = 0;
rafId = requestAnimationFrame(tick);
}
function stopLoop() {
running = false;
if (rafId) cancelAnimationFrame(rafId);
rafId = null;
fpsVal.textContent = "—";
}
// ---------- reduced-motion resting pose ----------
function renderResting() {
target.x = 0;
target.y = 0;
current.x = 0;
current.y = 0;
applyLayers(0, 0);
offVal.textContent = "0.00, 0.00";
}
function applyReducedState() {
if (settings.reduced) {
stopLoop();
renderResting();
rmNote.hidden = false;
stage.style.cursor = "default";
} else {
rmNote.hidden = true;
stage.style.cursor = settings.mode === "pointer" ? "crosshair" : "default";
startLoop();
}
}
// ---------- controls ----------
function syncSlider(input) {
const min = parseFloat(input.min);
const max = parseFloat(input.max);
const val = parseFloat(input.value);
const pct = ((val - min) / (max - min)) * 100;
input.style.setProperty("--fill", pct + "%");
}
function bindSlider(input, out, key) {
const update = () => {
settings[key] = parseFloat(input.value);
out.textContent = settings[key].toFixed(1) + "×";
syncSlider(input);
if (settings.reduced) applyLayers(0, 0); // reflect DOF/blur while resting
};
input.addEventListener("input", update);
update();
}
bindSlider(depthInput, depthOut, "depth");
bindSlider(dofInput, dofOut, "dof");
bindSlider(tiltInput, tiltOut, "tilt");
modeBtns.forEach((btn) => {
btn.addEventListener("click", () => {
const mode = btn.dataset.mode;
if (mode === settings.mode) return;
settings.mode = mode;
modeBtns.forEach((b) => {
const active = b === btn;
b.classList.toggle("is-active", active);
b.setAttribute("aria-checked", active ? "true" : "false");
});
modeVal.textContent = mode;
// reset field so the switch reads cleanly
target.x = 0;
target.y = 0;
if (mode === "scroll") onScroll();
if (!settings.reduced) {
stage.style.cursor = mode === "pointer" ? "crosshair" : "default";
}
});
});
reduceBtn.addEventListener("click", () => {
settings.reduced = !settings.reduced;
reduceBtn.setAttribute("aria-pressed", settings.reduced ? "true" : "false");
applyReducedState();
});
resetBtn.addEventListener("click", () => {
depthInput.value = "1";
dofInput.value = "1";
tiltInput.value = "1";
[depthInput, dofInput, tiltInput].forEach(syncSlider);
settings.depth = 1;
settings.dof = 1;
settings.tilt = 1;
depthOut.textContent = "1.0×";
dofOut.textContent = "1.0×";
tiltOut.textContent = "1.0×";
target.x = 0;
target.y = 0;
if (settings.reduced) applyLayers(0, 0);
});
// ---------- keyboard: nudge the scene when stage is focused ----------
stage.addEventListener("keydown", (e) => {
if (settings.reduced) return;
const step = 0.15;
let handled = true;
switch (e.key) {
case "ArrowLeft": target.x = clamp(target.x - step, -1, 1); break;
case "ArrowRight": target.x = clamp(target.x + step, -1, 1); break;
case "ArrowUp": target.y = clamp(target.y - step, -1, 1); break;
case "ArrowDown": target.y = clamp(target.y + step, -1, 1); break;
default: handled = false;
}
if (handled) e.preventDefault();
});
// ---------- wire input listeners ----------
stage.addEventListener("pointermove", onPointerMove, { passive: true });
stage.addEventListener("pointerleave", onPointerLeave, { passive: true });
stage.addEventListener("touchmove", onTouchMove, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
if (window.DeviceOrientationEvent) {
// Only auto-listen when permission isn't gated (non-iOS). iOS needs a user
// gesture + requestPermission, which we skip to stay self-contained.
if (typeof DeviceOrientationEvent.requestPermission !== "function") {
window.addEventListener("deviceorientation", onOrient, { passive: true });
}
}
// ---------- init ----------
function init() {
settings.reduced = mql.matches;
reduceBtn.setAttribute("aria-pressed", settings.reduced ? "true" : "false");
applyReducedState();
if (!settings.reduced && settings.mode === "scroll") onScroll();
}
if (typeof mql.addEventListener === "function") {
mql.addEventListener("change", (e) => {
settings.reduced = e.matches;
reduceBtn.setAttribute("aria-pressed", settings.reduced ? "true" : "false");
applyReducedState();
});
}
function clamp(v, lo, hi) {
return v < lo ? lo : v > hi ? hi : v;
}
init();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Parallax Depth Scene</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="wrap">
<header class="head">
<div class="head-copy">
<p class="eyebrow">UI · Motion</p>
<h1>Parallax Depth Scene</h1>
<p class="sub">
Five stacked planes drift at depth-scaled rates. Move your pointer over the
scene — or switch to scroll drive — to crane the diorama. Blur, scale and a
moving key light cue distance.
</p>
</div>
<div class="mode" role="radiogroup" aria-label="Parallax drive mode">
<button class="mode-btn is-active" role="radio" aria-checked="true" data-mode="pointer">
Pointer
</button>
<button class="mode-btn" role="radio" aria-checked="false" data-mode="scroll">
Scroll
</button>
</div>
</header>
<section class="stage-shell">
<div class="stage" id="stage" tabindex="0" aria-label="Interactive parallax diorama. Move pointer to explore depth.">
<div class="scene" id="scene">
<!-- depth from far (0) to near (5) -->
<div class="layer layer--sky" data-depth="0.06" aria-hidden="true">
<div class="sky-glow"></div>
<div class="star" style="--x:14%; --y:22%; --s:2px"></div>
<div class="star" style="--x:32%; --y:12%; --s:1px"></div>
<div class="star" style="--x:68%; --y:18%; --s:2px"></div>
<div class="star" style="--x:82%; --y:30%; --s:1px"></div>
<div class="star" style="--x:52%; --y:8%; --s:1px"></div>
<div class="star" style="--x:90%; --y:14%; --s:2px"></div>
</div>
<div class="layer layer--sun" data-depth="0.16" aria-hidden="true">
<div class="sun"></div>
</div>
<div class="layer layer--hills-far" data-depth="0.32" aria-hidden="true">
<svg viewBox="0 0 400 200" preserveAspectRatio="none" class="ridge">
<path d="M0 150 Q 60 96 120 128 T 240 118 T 400 132 L400 200 L0 200 Z" />
</svg>
</div>
<div class="layer layer--hills-mid" data-depth="0.55" aria-hidden="true">
<svg viewBox="0 0 400 200" preserveAspectRatio="none" class="ridge">
<path d="M0 168 Q 80 120 150 150 T 300 138 T 400 158 L400 200 L0 200 Z" />
</svg>
</div>
<div class="layer layer--monolith" data-depth="0.82" aria-hidden="true">
<div class="monolith"></div>
</div>
<div class="layer layer--trees" data-depth="1.15" aria-hidden="true">
<div class="tree" style="--tx:12%; --th:58%"></div>
<div class="tree" style="--tx:26%; --th:74%"></div>
<div class="tree" style="--tx:78%; --th:66%"></div>
<div class="tree" style="--tx:90%; --th:80%"></div>
</div>
<div class="layer layer--fore" data-depth="1.55" aria-hidden="true">
<div class="grass"></div>
</div>
<div class="frame" aria-hidden="true"></div>
</div>
<p class="rm-note" id="rmNote" hidden>
Reduced motion is on — the scene is resting. Use the controls below to explore depth.
</p>
</div>
</section>
<section class="controls" aria-label="Scene controls">
<div class="control">
<div class="control-label">
<label for="depth">Depth intensity</label>
<output id="depthOut">1.0×</output>
</div>
<input id="depth" type="range" min="0" max="2" step="0.05" value="1" />
</div>
<div class="control">
<div class="control-label">
<label for="dof">Depth-of-field</label>
<output id="dofOut">1.0×</output>
</div>
<input id="dof" type="range" min="0" max="2" step="0.05" value="1" />
</div>
<div class="control">
<div class="control-label">
<label for="tilt">Plane tilt</label>
<output id="tiltOut">1.0×</output>
</div>
<input id="tilt" type="range" min="0" max="2" step="0.05" value="1" />
</div>
<div class="control control--toggles">
<button id="reduce" class="toggle" aria-pressed="false">
<span class="dot"></span> Reduced motion
</button>
<button id="reset" class="ghost">Reset</button>
</div>
</section>
<footer class="foot">
<div class="readout" aria-live="off">
<span class="chip"><b id="modeVal">pointer</b> drive</span>
<span class="chip">offset <b id="offVal">0.00, 0.00</b></span>
<span class="chip">fps <b id="fpsVal">—</b></span>
</div>
<p class="tech">
Layers animate only <code>transform: translate3d()</code> + <code>filter</code> —
GPU-composited, zero layout thrash. A spring chases the target each
<code>requestAnimationFrame</code>.
</p>
</footer>
</main>
<script src="script.js"></script>
</body>
</html>A multi-layer depth scene that turns a flat card into a diorama. Five stacked planes — sky, distant hills, mid trees, a floating monolith and a foreground frame — each move at a different rate in response to input, so parallax offset scales with perceived distance. Nearer planes translate further and tilt more; farther ones stay put, gaining a touch of blur and desaturation so your eye reads them as receding.
Two drive modes share the same math. Pointer mode maps cursor position (or touch, or device tilt on mobile) to a normalized [-1, 1] vector that pushes each layer along X, Y and a subtle Z-rotation. Scroll mode maps vertical scroll progress to the same field, so the scene “cranes” as the page moves. A spring-smoothed value chases the raw target every frame via requestAnimationFrame, keeping motion buttery at 60fps and never snapping. Depth-of-field, per-layer scale and a moving key-light shadow sell the volume.
The technique is a compositor-friendly parallax: everything animates through transform: translate3d() and filter, so layers stay on the GPU and never trigger layout. Sliders expose depth intensity and DOF strength live, and the whole thing honors prefers-reduced-motion — under that setting the animation loop halts and the scene renders in a calm, centered resting pose with no drift.