Scroll-Linked 3D Scene
A sticky 3D exploded-view scene built with CSS 3D transforms that scrubs its assembly, rotation and layer separation directly from scroll progress, with a live progress rail and a reduced-motion crossfade fallback.
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 {
scroll-behavior: smooth;
}
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font-family: "Inter", system-ui, -apple-system, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
overflow-x: hidden;
}
code {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.9em;
background: var(--surface-2);
border: 1px solid var(--line);
padding: 0.05em 0.4em;
border-radius: 6px;
color: var(--accent-2);
}
/* ---------- Top bar ---------- */
.topbar {
position: sticky;
top: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 22px;
background: rgba(12, 13, 16, 0.72);
backdrop-filter: blur(12px);
border-bottom: 1px solid var(--line);
}
.brand {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
letter-spacing: -0.01em;
}
.brand strong {
color: var(--accent);
}
.brand .dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: 0 0 14px rgba(139, 92, 246, 0.7);
}
.hint {
margin: 0;
font-size: 12.5px;
color: var(--muted);
}
/* ---------- Sections ---------- */
main {
max-width: 100%;
}
.lede,
.outro {
max-width: 720px;
margin: 0 auto;
padding: 96px 24px;
text-align: center;
}
.lede {
padding-top: 120px;
}
.lede h1 {
font-size: clamp(28px, 5vw, 46px);
line-height: 1.1;
letter-spacing: -0.02em;
margin: 0 0 20px;
background: linear-gradient(180deg, var(--ink), var(--muted));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
.lede p,
.outro p {
font-size: 16px;
color: var(--muted);
margin: 0 auto;
max-width: 560px;
}
.outro h2 {
font-size: clamp(22px, 4vw, 30px);
letter-spacing: -0.01em;
margin: 0 0 16px;
}
.scroll-cue {
margin-top: 40px;
display: inline-flex;
flex-direction: column;
align-items: center;
gap: 6px;
color: var(--accent-2);
font-size: 12px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.14em;
}
.scroll-cue svg {
animation: bob 1.8s ease-in-out infinite;
}
@keyframes bob {
0%, 100% { transform: translateY(0); opacity: 0.6; }
50% { transform: translateY(6px); opacity: 1; }
}
/* ---------- Scroll track + sticky stage ---------- */
.track {
position: relative;
height: 420vh; /* tall track = scroll runway */
}
.sticky {
position: sticky;
top: 0;
height: 100vh;
display: grid;
grid-template-rows: auto 1fr auto;
align-items: center;
overflow: hidden;
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
background:
radial-gradient(120% 80% at 50% -10%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(90% 70% at 50% 120%, rgba(34, 211, 238, 0.12), transparent 60%),
var(--bg);
}
/* subtle dotted backdrop */
.sticky::before {
content: "";
position: absolute;
inset: 0;
background-image: radial-gradient(var(--line) 1px, transparent 1px);
background-size: 26px 26px;
mask-image: radial-gradient(70% 60% at 50% 45%, #000 40%, transparent 80%);
opacity: 0.5;
pointer-events: none;
}
/* ---------- Controls ---------- */
.controls {
position: relative;
z-index: 5;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 10px;
padding: 22px 16px 4px;
}
.ctrl {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: inherit;
font-size: 13px;
font-weight: 500;
color: var(--ink);
background: var(--surface);
border: 1px solid var(--line-2);
border-radius: 999px;
padding: 8px 14px;
cursor: pointer;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.12s ease;
}
.ctrl:hover {
border-color: var(--accent);
background: var(--surface-2);
}
.ctrl:active {
transform: translateY(1px);
}
.ctrl:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
.ctrl-key {
color: var(--muted);
}
.ctrl-val {
color: var(--accent-2);
font-weight: 600;
}
.ctrl-accent {
background: linear-gradient(135deg, rgba(139, 92, 246, 0.22), rgba(34, 211, 238, 0.16));
border-color: var(--accent);
}
/* ---------- 3D Stage ---------- */
.stage {
position: relative;
z-index: 4;
display: grid;
place-items: center;
perspective: 1200px;
perspective-origin: 50% 44%;
padding: 8px;
}
.scene {
position: relative;
width: min(280px, 62vw);
height: min(280px, 62vw);
transform-style: preserve-3d;
will-change: transform;
}
.layer {
position: absolute;
inset: 0;
transform-style: preserve-3d;
will-change: transform;
}
.panel {
position: absolute;
inset: 0;
border-radius: var(--r-lg);
border: 1px solid var(--line-2);
background: var(--surface);
box-shadow:
0 20px 50px -20px rgba(0, 0, 0, 0.8),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
backface-visibility: hidden;
}
.panel .tag {
position: absolute;
left: 12px;
bottom: 10px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--ink);
background: rgba(12, 13, 16, 0.6);
border: 1px solid var(--line);
padding: 3px 8px;
border-radius: 999px;
backdrop-filter: blur(4px);
}
/* Substrate */
.panel-base {
background:
linear-gradient(135deg, #202128, #14151a);
}
.grid-face {
position: absolute;
inset: 0;
background-image:
linear-gradient(var(--line) 1px, transparent 1px),
linear-gradient(90deg, var(--line) 1px, transparent 1px);
background-size: 22px 22px;
opacity: 0.7;
}
/* Logic board */
.panel-board {
background: linear-gradient(135deg, #0f2b24, #0c1a17);
}
.traces {
position: absolute;
inset: 0;
background:
repeating-linear-gradient(90deg, transparent 0 18px, rgba(52, 211, 153, 0.28) 18px 19px),
repeating-linear-gradient(0deg, transparent 0 30px, rgba(52, 211, 153, 0.18) 30px 31px);
mask-image: radial-gradient(80% 80% at 50% 50%, #000 30%, transparent 90%);
}
/* Cells */
.panel-cells {
background: linear-gradient(135deg, #2a1e0d, #1a1408);
}
.cells {
position: absolute;
inset: 14px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.cells::before,
.cells::after { content: ""; }
.cells {
background-image:
linear-gradient(rgba(251, 191, 36, 0.3), rgba(251, 191, 36, 0.05));
border-radius: var(--r-md);
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.25);
}
/* Display */
.panel-screen {
background: linear-gradient(160deg, #171826, #101120);
}
.ui-mock {
position: absolute;
inset: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ui-bar {
height: 10px;
width: 55%;
border-radius: 6px;
background: linear-gradient(90deg, var(--accent), var(--accent-2));
}
.ui-row {
height: 8px;
border-radius: 6px;
background: var(--line-2);
}
.ui-row.short { width: 60%; }
.ui-chip {
margin-top: auto;
height: 26px;
width: 92px;
border-radius: 8px;
background: rgba(139, 92, 246, 0.25);
border: 1px solid var(--accent);
}
/* Cover glass */
.panel-glass {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.10), rgba(255, 255, 255, 0.02));
border-color: rgba(255, 255, 255, 0.28);
backdrop-filter: blur(2px);
}
.glass-glint {
position: absolute;
inset: 0;
background: linear-gradient(115deg, transparent 30%, rgba(255, 255, 255, 0.22) 46%, transparent 60%);
}
/* ---------- Captions ---------- */
.captions {
position: relative;
z-index: 5;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 28px;
}
.cap {
grid-area: 1 / 1;
margin: 0;
font-size: 13.5px;
color: var(--muted);
font-weight: 500;
text-align: center;
padding: 0 16px;
opacity: 0;
transform: translateY(6px);
transition: opacity 0.35s ease, transform 0.35s ease;
}
.cap.on {
opacity: 1;
transform: translateY(0);
}
.cap.on strong,
.cap b { color: var(--ink); }
/* ---------- Progress rail ---------- */
.rail {
position: fixed;
right: 18px;
top: 50%;
transform: translateY(-50%);
z-index: 30;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
opacity: 0;
transition: opacity 0.3s ease;
}
.rail.show { opacity: 1; }
.rail-track {
position: relative;
width: 5px;
height: 190px;
border-radius: 999px;
background: var(--surface-2);
border: 1px solid var(--line);
overflow: hidden;
}
.rail-fill {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 0%;
background: linear-gradient(180deg, var(--accent-2), var(--accent));
border-radius: 999px;
transition: height 0.08s linear;
}
.rail-meta {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.rail-pct {
font-family: "JetBrains Mono", monospace;
font-size: 12px;
font-weight: 500;
color: var(--ink);
}
.rail-stage {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
max-width: 60px;
text-align: center;
}
/* ---------- Reduced-motion fallback ---------- */
.rm-fallback {
position: absolute;
inset: 0;
z-index: 6;
display: grid;
place-items: center;
padding: 24px;
}
.rm-card {
grid-area: 1 / 1;
width: min(300px, 74vw);
height: 200px;
border-radius: var(--r-lg);
background: var(--surface);
border: 1px solid var(--line-2);
display: grid;
place-items: center;
font-size: 17px;
font-weight: 600;
color: var(--ink);
box-shadow: 0 24px 60px -24px rgba(0, 0, 0, 0.8);
opacity: 0;
transition: opacity 0.4s ease;
}
.rm-card.on { opacity: 1; }
.rm-note {
position: absolute;
bottom: 28px;
left: 0;
right: 0;
text-align: center;
font-size: 12.5px;
color: var(--muted);
margin: 0;
}
.sr-progress {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
/* ---------- Responsive ---------- */
@media (max-width: 520px) {
.topbar { flex-direction: column; align-items: flex-start; gap: 4px; }
.hint { font-size: 11.5px; }
.lede, .outro { padding: 72px 20px; }
.track { height: 380vh; }
.rail { right: 10px; }
.rail-track { height: 140px; }
.controls { gap: 8px; }
.ctrl { font-size: 12px; padding: 7px 12px; }
.scene { width: 66vw; height: 66vw; }
}
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.scroll-cue svg { animation: none; }
}(function () {
"use strict";
var track = document.getElementById("track");
var scene = document.getElementById("scene");
var layers = Array.prototype.slice.call(document.querySelectorAll(".layer"));
var caps = Array.prototype.slice.call(document.querySelectorAll(".cap"));
var railFill = document.getElementById("railFill");
var railPct = document.getElementById("railPct");
var railStage = document.getElementById("railStage");
var rail = document.querySelector(".rail");
var srProgress = document.getElementById("srProgress");
var rmFallback = document.getElementById("rmFallback");
var rmCards = Array.prototype.slice.call(document.querySelectorAll(".rm-card"));
var modeBtn = document.getElementById("modeBtn");
var modeVal = document.getElementById("modeVal");
var driftBtn = document.getElementById("driftBtn");
var driftVal = document.getElementById("driftVal");
var assembleBtn = document.getElementById("assembleBtn");
var reduceQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
/* ---- State ---- */
var mode = "explode"; // "explode" | "rotate"
var driftOn = true;
var reduce = reduceQuery.matches;
var rawP = 0; // scroll-derived progress 0..1
var dispP = 0; // eased/animated progress actually rendered
var forced = null; // when snap-to-assembled is active, target progress
var lastStage = -1;
var ticking = false;
var driftPhase = 0;
/* ---- Per-layer explode config (Z depth + parallax offset) ---- */
var layerCfg = [
{ z: -160, ox: -0.9, oy: 0.7 },
{ z: -80, ox: -0.5, oy: 0.35 },
{ z: 0, ox: 0, oy: 0 },
{ z: 90, ox: 0.55, oy: -0.4 },
{ z: 190, ox: 1.0, oy: -0.8 }
];
var stageLabels = ["idle", "tilt", "explode", "inspect"];
function clamp(v, a, b) {
return v < a ? a : v > b ? b : v;
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
/* Compute scroll progress of the sticky stage through its track. */
function computeProgress() {
var rect = track.getBoundingClientRect();
var vh = window.innerHeight || document.documentElement.clientHeight;
// total scrollable distance while the stage is pinned
var scrollable = rect.height - vh;
if (scrollable <= 0) return 0;
var travelled = clamp(-rect.top, 0, scrollable);
return travelled / scrollable;
}
/* Ease progress a touch for buttery scrub without lag. */
function ease(t) {
// smoothstep
return t * t * (3 - 2 * t);
}
function render() {
ticking = false;
if (reduce) {
renderReduced();
return;
}
// choose target: forced snap or live scroll
var target = forced != null ? forced : rawP;
// approach target for smooth motion
dispP += (target - dispP) * 0.18;
if (Math.abs(target - dispP) < 0.0008) dispP = target;
var p = clamp(dispP, 0, 1);
var e = ease(p);
/* Scene rotation: builds up over first ~40% then holds/rotates. */
var rotX = lerp(2, 18, ease(clamp(p / 0.45, 0, 1)));
var rotY = mode === "rotate"
? lerp(-30, 30, e)
: lerp(-18, 22, e);
var drift = 0;
if (driftOn) {
driftPhase += 0.008;
drift = Math.sin(driftPhase) * 4;
}
scene.style.transform =
"rotateX(" + rotX.toFixed(2) + "deg) rotateY(" + (rotY + drift).toFixed(2) + "deg)";
/* Explode factor: 0 until 25% progress, ramps to 1 by 95%. */
var explode = mode === "explode"
? ease(clamp((p - 0.22) / 0.7, 0, 1))
: ease(clamp((p - 0.4) / 0.5, 0, 1)) * 0.35;
for (var i = 0; i < layers.length; i++) {
var cfg = layerCfg[i];
var z = cfg.z * explode;
var tx = cfg.ox * 46 * explode;
var ty = cfg.oy * 40 * explode;
layers[i].style.transform =
"translate3d(" + tx.toFixed(1) + "px," + ty.toFixed(1) + "px," + z.toFixed(1) + "px)";
}
updateHud(p);
// keep animating while drifting or approaching target
if (driftOn || forced != null || Math.abs((forced != null ? forced : rawP) - dispP) > 0.0008) {
requestFrame();
}
}
function renderReduced() {
var p = clamp(rawP, 0, 1);
var idx = Math.min(rmCards.length - 1, Math.floor(p * rmCards.length));
for (var i = 0; i < rmCards.length; i++) {
rmCards[i].classList.toggle("on", i === idx);
}
updateHud(p);
}
function updateHud(p) {
var pct = Math.round(p * 100);
railFill.style.height = pct + "%";
railPct.textContent = pct + "%";
var stage = clamp(Math.floor(p * 4), 0, 3);
railStage.textContent = stageLabels[stage];
if (stage !== lastStage) {
lastStage = stage;
for (var i = 0; i < caps.length; i++) {
caps[i].classList.toggle("on", i === stage);
}
srProgress.textContent =
"Assembly stage " + (stage + 1) + " of 4, " + pct + " percent.";
}
}
var frameReq = null;
function requestFrame() {
if (ticking) return;
ticking = true;
frameReq = requestAnimationFrame(render);
}
/* ---- Scroll: only store value, render in rAF ---- */
function onScroll() {
rawP = computeProgress();
if (forced != null) {
// any manual scroll cancels the snap
forced = null;
assembleBtn.classList.remove("ctrl-accent-active");
}
requestFrame();
}
/* ---- Rail visibility tied to the track ---- */
var io = null;
if ("IntersectionObserver" in window) {
io = new IntersectionObserver(
function (entries) {
rail.classList.toggle("show", entries[0].isIntersecting);
},
{ threshold: 0.02 }
);
io.observe(track);
} else {
rail.classList.add("show");
}
/* ---- Controls ---- */
modeBtn.addEventListener("click", function () {
mode = mode === "explode" ? "rotate" : "explode";
modeVal.textContent = mode === "explode" ? "Explode" : "Rotate";
modeBtn.dataset.mode = mode;
modeBtn.setAttribute("aria-pressed", String(mode === "explode"));
requestFrame();
});
driftBtn.addEventListener("click", function () {
driftOn = !driftOn;
driftVal.textContent = driftOn ? "On" : "Off";
driftBtn.setAttribute("aria-pressed", String(driftOn));
requestFrame();
});
assembleBtn.addEventListener("click", function () {
// toggle between fully assembled (0) and fully exploded (1)
forced = forced === 0 ? 1 : 0;
assembleBtn.textContent = forced === 0 ? "Snap to exploded" : "Snap to assembled";
requestFrame();
});
/* ---- Reduced-motion setup + live toggle ---- */
function applyMotionPref() {
reduce = reduceQuery.matches;
if (reduce) {
rmFallback.hidden = false;
scene.parentElement.style.display = "none";
caps.forEach(function (c) { c.style.display = "none"; });
} else {
rmFallback.hidden = true;
scene.parentElement.style.display = "";
caps.forEach(function (c) { c.style.display = ""; });
}
lastStage = -1;
requestFrame();
}
if (reduceQuery.addEventListener) {
reduceQuery.addEventListener("change", applyMotionPref);
} else if (reduceQuery.addListener) {
reduceQuery.addListener(applyMotionPref);
}
/* ---- Init ---- */
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
applyMotionPref();
onScroll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Scroll-Linked 3D 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>
<header class="topbar">
<div class="brand">
<span class="dot" aria-hidden="true"></span>
<span>Scroll-Linked <strong>3D</strong> Scene</span>
</div>
<p class="hint">Scroll the page to scrub the assembly · sticky viewport below</p>
</header>
<main>
<!-- Intro -->
<section class="lede">
<h1>Scrub a 3D exploded view with your scroll</h1>
<p>
The viewport stays pinned while a tall track drives a normalized progress
value <code>0 → 1</code>. That single number rotates the cluster, explodes
the layers along Z, and stages the captions — pure CSS 3D transforms,
no WebGL.
</p>
<div class="scroll-cue" aria-hidden="true">
<span>Scroll</span>
<svg viewBox="0 0 24 24" width="18" height="18"><path d="M12 5v14M6 13l6 6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
</section>
<!-- The tall scroll track. The sticky stage lives inside. -->
<section class="track" id="track" aria-label="Scroll-linked 3D assembly">
<div class="sticky">
<!-- Controls -->
<div class="controls" role="group" aria-label="Scene controls">
<button type="button" id="modeBtn" class="ctrl" aria-pressed="true" data-mode="explode">
<span class="ctrl-key">Mode</span><span class="ctrl-val" id="modeVal">Explode</span>
</button>
<button type="button" id="driftBtn" class="ctrl" aria-pressed="true">
<span class="ctrl-key">Idle drift</span><span class="ctrl-val" id="driftVal">On</span>
</button>
<button type="button" id="assembleBtn" class="ctrl ctrl-accent">
Snap to assembled
</button>
</div>
<!-- 3D stage -->
<div class="stage" id="stage" aria-hidden="false">
<div class="scene" id="scene">
<div class="layer" data-layer="0">
<div class="panel panel-base">
<div class="grid-face"></div>
<span class="tag">Substrate</span>
</div>
</div>
<div class="layer" data-layer="1">
<div class="panel panel-board">
<div class="traces"></div>
<span class="tag">Logic board</span>
</div>
</div>
<div class="layer" data-layer="2">
<div class="panel panel-cells">
<div class="cells"></div>
<span class="tag">Cells</span>
</div>
</div>
<div class="layer" data-layer="3">
<div class="panel panel-screen">
<div class="ui-mock">
<div class="ui-bar"></div>
<div class="ui-row"></div>
<div class="ui-row short"></div>
<div class="ui-chip"></div>
</div>
<span class="tag">Display</span>
</div>
</div>
<div class="layer" data-layer="4">
<div class="panel panel-glass">
<div class="glass-glint"></div>
<span class="tag">Cover glass</span>
</div>
</div>
</div>
</div>
<!-- Stage caption, staged by progress -->
<div class="captions" aria-live="polite">
<p class="cap" data-cap="0">01 · Layers stacked flat, awaiting assembly.</p>
<p class="cap" data-cap="1">02 · Cluster tilts into perspective view.</p>
<p class="cap" data-cap="2">03 · Panels explode apart along the Z axis.</p>
<p class="cap" data-cap="3">04 · Fully separated — inspect every layer.</p>
</div>
<!-- Reduced-motion fallback -->
<div class="rm-fallback" id="rmFallback" hidden>
<div class="rm-card" data-rm="0"><span>Substrate</span></div>
<div class="rm-card" data-rm="1"><span>Logic board</span></div>
<div class="rm-card" data-rm="2"><span>Cells</span></div>
<div class="rm-card" data-rm="3"><span>Display + glass</span></div>
<p class="rm-note">Reduced-motion: layers crossfade instead of moving in 3D.</p>
</div>
</div>
</section>
<!-- Outro -->
<section class="outro">
<h2>How it works</h2>
<p>
A clamped ratio of how far the sticky stage has travelled through its track
becomes <code>p</code>. Transforms are interpolated from <code>p</code> and
applied once per frame inside <code>requestAnimationFrame</code> — the scroll
handler only stores the latest position, so there is no per-event layout thrash.
</p>
</section>
</main>
<!-- Fixed progress rail -->
<aside class="rail" aria-hidden="true">
<div class="rail-track"><span class="rail-fill" id="railFill"></span></div>
<div class="rail-meta">
<span class="rail-pct" id="railPct">0%</span>
<span class="rail-stage" id="railStage">idle</span>
</div>
</aside>
<div class="sr-progress" role="status" aria-live="off" id="srProgress"></div>
<script src="script.js"></script>
</body>
</html>The Scroll-Linked 3D Scene pins a perspective viewport while a tall scroll track underneath it drives the animation. As you scroll, a normalized progress value (0 → 1) is mapped onto every layer of a stacked device card: the cluster rotates in X and Y, the panels explode apart along Z, and captions fade in at staged thresholds. The whole thing is driven by CSS transform-style: preserve-3d — no WebGL, no libraries — so it stays crisp at any DPR and animates on the compositor.
Interaction is entirely scroll-native: scrub up and down to assemble or disassemble the stack, and a fixed rail on the right reports the exact progress percentage and active stage. A control strip lets you toggle auto-rotate idle drift, flip between explode and rotate-only choreographies, and snap to the fully-assembled frame. Scroll updates are batched through requestAnimationFrame and read via a single IntersectionObserver + scroll listener, so there is no layout thrash per frame.
The component demonstrates the core technique behind scroll-scrubbed product reveals: decouple scroll position from animation state by computing a clamped progress ratio for a sticky element, then interpolate transforms from it. When prefers-reduced-motion: reduce is set, the 3D scrub is replaced by a simple staged opacity crossfade, keeping the narrative intact without vestibular motion.