Scroll-driven scene transition
Scroll scrubs between full-bleed scenes: the outgoing panel rotates away in 3D as the next one swings in, with the caption cross-fading rather than being rewritten.
How it works
- Scroll progress becomes a continuous index — 0 → 2 across three panels. The fractional part is the transition, which is what makes it scrub in both directions and stop wherever you stop. An integer index with a transition between values can only ever play forwards.
- Each panel positions itself from its distance to that index:
translateX(d × 68%) rotateY(d × −26deg) translateZ(−|d| × 190px). One expression covers incoming, centred and outgoing — there is no separate 'enter' and 'exit' state to keep in sync. perspectivelives on the stage, not on the panels. Put it on each panel and every one gets its own vanishing point, so they rotate about themselves instead of about the room.- Off-centre panels are dimmed with a solid overlay, not with
opacity. Fading the panel itself lets the background show through and washes out its own contrast; a dark veil on top keeps the artwork saturated and just puts it in shadow. - Panels more than 1.6 slots away are set to
visibility: hidden, so at most three are ever composited no matter how long the list gets. - The caption is one element per scene, cross-faded. Rewriting the text of a single element pops between scenes with no overlap — and it makes a screen reader announce every intermediate string as you scroll.
Numbers
- Scroll distance
- 420% of the viewport
- Perspective
- 1400px, on the stage
- Panel offset
- translateX 68% per slot
- Rotation
- −26deg per slot
- Depth
- −190px at one slot away
- Veil
- solid #05050a, up to 0.68
Prompt
Paste this into any coding model to get the effect from scratch. It states every number explicitly — vague words like “smooth” or “modern” produce a different result every time.
Build a scroll-driven 3D scene transition in vanilla HTML/CSS/JS. A scroll container with a 420%-tall track and a sticky stage; put perspective: 1400px on the stage, never on the individual panels. Convert scroll progress into a CONTINUOUS index from 0 to (count − 1) and position every panel from its signed distance d to that index with a single expression: translateX(d * 68%) rotateY(d * -26deg) translateZ(-abs(d) * 190px). Do not use discrete states with CSS transitions — the fractional index is the transition, and that is what makes it scrub both ways. Dim off-centre panels with a solid dark overlay whose opacity rises with abs(d) up to 0.68, not with opacity on the panel itself, so the artwork keeps its contrast. Set visibility: hidden on panels more than 1.6 slots away. Render one caption element per scene and cross-fade them by abs(d); never rewrite the text of a single caption element. Set z-index from abs(d) and register the scroll listener passive.Source
The whole file. It is what the sample above is running — copy it into an .html file and it works with nothing else.
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven scene transition</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { height: 100%; }
body { font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; }
.scroller { height: 100%; overflow-y: auto; background: #08080d; }
.track { height: 420%; }
.stage {
position: sticky; top: 0; height: 100%;
overflow: hidden;
perspective: 1400px; /* the panels are real 3D, not skewed 2D */
display: grid; place-items: center;
background: radial-gradient(70% 60% at 50% 45%, #16162a, #08080d 75%);
}
.panel {
position: absolute;
width: 62%; aspect-ratio: 16 / 9;
border-radius: 10px; overflow: hidden;
transform-style: preserve-3d;
/* No transition. Every frame's transform is written from scroll position,
so a transition here would fight the scrub and add lag on fast wheels. */
will-change: transform, opacity;
}
.panel .art { position: absolute; inset: 0; }
.panel .tag {
position: absolute; left: 14px; bottom: 12px;
font-size: 12px; font-weight: 600; letter-spacing: 0.02em; color: #fff;
text-shadow: 0 1px 8px #000000aa;
}
/* Panels away from centre are dimmed by an overlay rather than opacity, so
the panel keeps its own contrast instead of fading into the background. */
.panel .veil { position: absolute; inset: 0; background: #05050a; }
.a1 { background: radial-gradient(60% 80% at 30% 25%, #ff5abf, transparent 60%), linear-gradient(140deg, #7b2ff7, #1a1030); }
.a2 { background: radial-gradient(60% 80% at 70% 30%, #29e0ff, transparent 60%), linear-gradient(140deg, #0a4d8c, #071427); }
.a3 { background: radial-gradient(60% 80% at 40% 70%, #ffd23e, transparent 60%), linear-gradient(140deg, #e0620d, #2a1206); }
.caption {
position: absolute; left: 6%; bottom: 8%;
color: #fff; pointer-events: none;
}
.caption b { display: block; font-size: clamp(15px, 2.4vw, 22px); letter-spacing: -0.015em; }
.caption i {
display: block; margin-top: 4px; font-style: normal;
font-size: 10.5px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff7a;
}
.caption span { display: block; position: absolute; inset: 0; }
.cue {
position: absolute; right: 5%; bottom: 9%;
font-size: 11px; letter-spacing: 0.18em; text-transform: uppercase; color: #ffffff55;
}
</style>
<div class="scroller" id="scroller">
<div class="track">
<div class="stage" id="stage">
<div class="panel" data-i="0"><div class="art a1"></div><div class="veil"></div><div class="tag">Hello, Fortnite</div></div>
<div class="panel" data-i="1"><div class="art a2"></div><div class="veil"></div><div class="tag">Shin Sekai</div></div>
<div class="panel" data-i="2"><div class="art a3"></div><div class="veil"></div><div class="tag">Run for Money</div></div>
<div class="caption" id="caption"></div>
<div class="cue">scroll ↓</div>
</div>
</div>
</div>
<script>
const scroller = document.getElementById("scroller");
const panels = [...document.querySelectorAll(".panel")];
const caption = document.getElementById("caption");
const TITLES = [
["Hello, Fortnite", "KizunaAI"],
["Shin Sekai", "RADWIMPS"],
["Run for Money", "Created in Fortnite"],
];
// One caption element per scene, cross-faded. Rewriting the text of a single
// element instead would make it pop between scenes with no overlap.
TITLES.forEach(([t, s]) => {
const el = document.createElement("span");
el.innerHTML = "<b></b><i></i>";
el.querySelector("b").textContent = t;
el.querySelector("i").textContent = s;
caption.appendChild(el);
});
const caps = [...caption.children];
const update = () => {
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
// Continuous index: 0 → 2 across the scroll. The fractional part IS the
// transition, which is what makes it scrubbable in both directions.
const pos = p * (panels.length - 1);
panels.forEach((el, i) => {
const d = i - pos; // -1 = gone left, 0 = centred, 1 = waiting right
const k = Math.min(1, Math.abs(d));
el.style.transform =
`translateX(${d * 68}%) rotateY(${d * -26}deg) translateZ(${-k * 190}px)`;
el.style.zIndex = String(100 - Math.round(k * 100));
el.querySelector(".veil").style.opacity = (k * 0.68).toFixed(3);
// Fully hide only what is more than one slot away, so at most three
// panels are ever composited.
el.style.visibility = Math.abs(d) > 1.6 ? "hidden" : "visible";
});
caps.forEach((el, i) => {
const k = Math.min(1, Math.abs(i - pos));
el.style.opacity = (1 - k).toFixed(3);
el.style.transform = `translateY(${(i - pos) * 14}px)`;
});
};
scroller.addEventListener("scroll", update, { passive: true });
addEventListener("resize", update);
update();
</script>Where it breaks
- Rotated panels are still hit-testable across their full projected area. If they carry links, a panel edge-on at 26 degrees can still swallow clicks meant for the centred one — add
pointer-events: noneto anything not centred. rotateYon a large panel renders through the GPU's texture sampler; on low-DPI external displays the artwork edges can shimmer during the scrub. A 1px inset border on the panel hides it.- This eats 420% of viewport height in scroll for three panels. On a phone, where that is several thumb-flicks with nothing else on screen, cut the distance or the count — the effect that felt cinematic on a laptop reads as a page that will not end.
Seen in
@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:24