捲動驅動的場景切換
捲動在幾個整屏場景之間來回擦洗:當前面板帶著 3D 旋轉讓開,下一塊擺進來,圖註是交叉淡入而不是被改寫。
怎麼做到的
- 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.
參數
- 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
提示詞
把這段丟給任意一個寫程式的模型,就能從零長出這個效果。它把每個數值都寫死了 ——「絲滑」「現代感」這種詞每次生成出來的都不一樣。
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.原始碼
整個檔案。上面那個樣板跑的就是它 —— 存成 .html 打開就能用,不需要別的任何東西。
<!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>會翻車的地方
- 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.
出處
@菜心视觉设计 (Douyin) · effects catalogued from a screen recording of alche.jp · 0:24