Scroll-driven vortex transit
Scroll pushes the camera into a cloud tunnel: the hero fades, the bands balloon past the edges, the dark eye grows until it fills the frame, and the next chapter is what is on the other side.
How it works
- Same skeleton as every scroll-driven piece here: a 420%-tall track, a sticky
100vhstage, progress read from layout every time (scrollTop / (trackHeight − viewport)), never accumulated from wheel deltas. - The source is a rendered clip scrubbed by scroll. The sample is procedural so the file stays self-contained: 42 rings stacked in depth, each a handful of arcs whose start, length, width and tint are decided once at load. Scroll moves the camera forward by up to 6 ring-depths; the projection
radius = focal / depthis the entire tunnel illusion — near rings balloon past the frame, far ones crowd into the eye. - 🩸 Rings must be painted far → near, sorted by depth after the camera offset. Index order is not depth order once the modulo wraps; painting in index order puts a far ring on top of a near one for a few frames every time the camera passes a ring boundary, which reads as a flicker with no obvious cause.
- Bands are three concentric strokes (widths 1 / 0.62 / 0.3, alphas 0.22 / 0.3 / 0.42) rather than one — a single hard stroke reads as a machined ring, and clouds have no edge. Ring centres also wander on a slow sine with depth so the tunnel bends instead of drilling straight.
- Near bands thin out as their radius approaches the frame diagonal (alpha × (1 − 0.95·k)). Without that, every ring the camera passes through paints a near-vertical arc at the screen edge for a moment, and the edges strobe.
- The eye is a radial gradient whose radius grows with
eye²over progress 0.62–0.9 — quadratic so it lingers small and then swallows the frame. The hero drifts up 40px and fades over 0–0.3; the next chapter fades in over 0.86–1 with its labels rising 14px, so the two never share the screen. - A very slow idle spin (0.00004 rad/ms) keeps the picture alive while the reader is not scrolling, and is disabled under reduced motion — where the sample only repaints on scroll.
Numbers
- Scroll distance
- 420% of the viewport
- Rings · arcs
- 42 · 9 per ring
- Camera travel
- 6 ring-depths, smoothstep over 0.08–0.86
- Projection
- r = 0.58·min(W,H) / (0.25 + 0.22·z)
- Eye
- grows with eye² over 0.62–0.9
- Hero out / chapter in
- 0–0.3 · 0.86–1
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 vortex transit in vanilla HTML/CSS/JS. A scroll container with a 420%-tall track and a sticky stage (height: 100vh, never 100%) holding a full-size canvas; derive progress p from layout every time (scrollTop / (trackHeight - clientHeight)), never accumulate wheel deltas, and register the scroll listener passive. On the canvas draw a procedural cloud tunnel: 42 rings, each with 9 arcs whose start angle, arc length (0.8-3.0 rad), width factor, tint (72% steel blue rgb(118,146,188), 28% dust rgb(196,140,84)) and alpha are generated once from a seeded PRNG. The camera moves forward cam = smoothstep(slice(p, .08, .86)) * 6 ring-depths; each ring's depth is z = ((i - cam) mod 42), depth = 0.25 + 0.22 z, radius = 0.58 * min(W,H) / depth. Sort rings by z descending BEFORE painting (index order is not depth order after the modulo). Offset ring centres by sin(0.42 z + .6) * 10% and cos(0.37 z) * 7% of the focal length so the tunnel bends. Draw each arc as three concentric round-capped strokes (width 1 / .62 / .3 of 0.17·r·w, alpha .22 / .3 / .42 of the band alpha), fade far rings by (1 - z/42), and fade near rings to 5% as their radius approaches 0.75 of the frame diagonal; skip rings beyond that. Rotate arcs by 0.475 z + 0.11 i plus a 0.00004 rad/ms idle drift. Paint a black radial 'eye' at the centre whose radius grows with eye² where eye = slice(p, .62, .9), plus a vignette. Overlay an HTML hero (nav, eyebrow, three-part serif headline, lede) that translates up 40px and fades over p 0-0.3, and a next chapter (six scattered uppercase labels + a serif headline) that fades in over p 0.86-1 with labels rising 14px. Back the canvas at min(devicePixelRatio, 2). Under prefers-reduced-motion disable the idle drift and repaint only on scroll.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 vortex transit</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; background: #05070c; color: #fff; }
.scroller { height: 100%; overflow-y: auto; scrollbar-width: thin; }
.track { height: 420%; }
/* 🩸 100vh,不是 100%:.track 高 420%,100% 会把舞台撑成整条轨道那么高 */
.stage { position: sticky; top: 0; height: 100vh; overflow: hidden; background: #05070c; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* ---------- chapter 1: the atlas hero ---------- */
.hero { position: absolute; inset: 0; pointer-events: none; }
.nav {
position: absolute; left: 5%; right: 5%; top: 14px;
display: flex; justify-content: space-between; align-items: center;
font-size: 8.5px; letter-spacing: .16em; text-transform: uppercase; color: #ffffffb0;
}
.nav .pill { display: flex; gap: 14px; padding: 6px 12px; border-radius: 99px; background: #ffffff1a; border: 1px solid #ffffff22; }
.eyebrow {
position: absolute; left: 0; right: 0; top: 52px; text-align: center;
font-size: 8px; letter-spacing: .3em; text-transform: uppercase; color: #ffffff8c;
}
h1 {
position: absolute; left: 5%; right: 5%; top: 36%;
display: flex; justify-content: space-between; align-items: flex-end;
font: 400 40px/.92 "Playfair Display", "Didot", "Bodoni 72", Georgia, serif;
letter-spacing: .01em; text-transform: uppercase;
}
h1 span:nth-child(2) { font-size: 28px; padding-bottom: 4px; }
h1 span:last-child { text-align: right; }
.lede {
position: absolute; left: 0; right: 0; top: 72%; text-align: center;
font-size: 8px; line-height: 1.6; letter-spacing: .1em; text-transform: uppercase; color: #ffffffa8;
}
/* Scroll moves the hero as a whole: it drifts up 40px and fades over the
first 30% — the picture behind it is what keeps moving. */
.hero { transform: translateY(calc(var(--p1, 0) * -40px)); opacity: calc(1 - var(--p1, 0)); }
/* ---------- chapter 2: what you land in ---------- */
.after { position: absolute; inset: 0; pointer-events: none; opacity: var(--p3, 0); }
.after .tag {
position: absolute; font-size: 7.5px; letter-spacing: .22em; text-transform: uppercase; color: #ffffff6b;
transform: translateY(calc((1 - var(--p3, 0)) * 14px));
}
.after h2 {
position: absolute; left: 0; right: 0; top: 42%; text-align: center;
font: 400 36px/.95 "Playfair Display", "Didot", Georgia, serif; text-transform: uppercase;
transform: translateY(calc((1 - var(--p3, 0)) * 24px));
}
.cue {
position: absolute; right: 5%; bottom: 9%;
font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: #ffffff55;
opacity: calc(1 - var(--p1, 0));
}
.readout {
position: absolute; left: 5%; bottom: 9%;
font: 500 11px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; color: #ffffff70;
}
.readout b { color: #d9c1a2; font-weight: 600; }
</style>
<div class="scroller" id="scroller">
<div class="track">
<div class="stage" id="stage">
<canvas id="cv"></canvas>
<div class="hero">
<div class="nav"><span>◎ Void Atlas</span><span class="pill"><i>Mission</i><i>Signals</i><i>Archive</i><i>Field</i></span><span>Enter atlas</span></div>
<div class="eyebrow">Light becomes landscape</div>
<h1><span>The<br>new map</span><span>of</span><span>Deep<br>space</span></h1>
<div class="lede">An immersive atlas that turns distant light into<br>places you can move through.</div>
</div>
<div class="after">
<span class="tag" style="left:8%;top:12%">Atlas layer</span>
<span class="tag" style="left:54%;top:9%">Orbital memory</span>
<span class="tag" style="left:80%;top:16%">Signal drift</span>
<span class="tag" style="left:6%;top:70%">Edge of silence</span>
<span class="tag" style="left:38%;top:88%">Unknown coordinates</span>
<span class="tag" style="left:74%;top:78%">Mapped light</span>
<h2>We turn the<br>invisible into a map</h2>
</div>
<div class="readout">progress <b id="rp">0.000</b></div>
<div class="cue">scroll ↓</div>
</div>
</div>
</div>
<script>
const scroller = document.getElementById("scroller");
const stage = document.getElementById("stage");
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
const rp = document.getElementById("rp");
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ---------- the tunnel ----------
A rendered clip would be scrubbed with `video.currentTime = p * duration`.
This one is procedural so the file stays self-contained: RINGS cloud bands
stacked in depth, each drawn as a handful of arcs with a random start,
length and tint decided ONCE at load. Scroll moves the camera forward, and
the projection is the whole trick — radius = focal / depth — so bands near
the camera balloon past the edges and far bands crowd into the eye. */
const RINGS = 42, SEGS = 9, TWIST = 1.9;
let seed = 7;
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
const bands = Array.from({ length: RINGS }, () =>
Array.from({ length: SEGS }, () => ({
a0: rnd() * Math.PI * 2,
len: 0.8 + rnd() * 2.2,
w: 0.5 + rnd() * 0.9,
warm: rnd() < 0.28, // a few bands are dust-coloured
al: 0.22 + rnd() * 0.4,
})),
);
let W = 0, H = 0, DPR = 1;
function size() {
DPR = Math.min(devicePixelRatio || 1, 2);
W = stage.clientWidth; H = stage.clientHeight;
cv.width = W * DPR; cv.height = H * DPR;
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
paint(true);
}
const clamp01 = (v) => Math.min(1, Math.max(0, v));
const slice = (p, a, b) => clamp01((p - a) / (b - a));
const smooth = (x) => x * x * (3 - 2 * x);
let p = 0, drawn = -1, t0 = performance.now();
function paint(force) {
const now = performance.now();
const drift = reduce ? 0 : (now - t0) * 0.00004; // idle spin, barely there
if (!force && Math.abs(p - drawn) < 0.0005 && reduce) return;
drawn = p;
const cx = W * 0.52, cy = H * 0.46;
const focal = Math.min(W, H) * 0.58;
// camera travel: 0 → 6 ring-depths over the middle of the scroll
const cam = smooth(slice(p, 0.08, 0.86)) * 6;
// the eye: a black disc that the projection pushes towards the viewer
const eye = slice(p, 0.62, 0.9);
ctx.fillStyle = "#05070c"; ctx.fillRect(0, 0, W, H);
// far → near so near bands paint over far ones
const diag = Math.hypot(W, H), inner = Math.min(W, H) * 0.5;
// depth of each ring after the camera has moved `cam` rings forward,
// sorted far → near (the modulo wrap means index order is NOT depth order)
const order = bands.map((_, i) => ({ i, z: ((i - cam) % RINGS + RINGS) % RINGS })).sort((a, b) => b.z - a.z);
for (const { i, z } of order) {
const depth = 0.25 + z * 0.22; // never reaches 0
const r = focal / depth;
if (r > diag * 0.75) continue; // ballooned past the frame
// the tunnel bends: ring centres wander on a slow spiral with depth
const ox = cx + Math.sin(z * 0.42 + 0.6) * focal * 0.10;
const oy = cy + Math.cos(z * 0.37) * focal * 0.07;
let fade = clamp01(1 - z / RINGS) * clamp01(depth - 0.25) * 1.2;
fade *= 1 - 0.95 * clamp01((r - inner) / (diag * 0.75 - inner)); // near bands thin out
if (fade <= 0.01) continue;
const rot = z * TWIST * 0.25 + drift + i * 0.11; // bands twist with depth
for (const b of bands[i]) {
const a = Math.min(0.9, b.al * fade);
const col = b.warm ? "196,140,84" : "118,146,188";
// three concentric strokes = a soft-edged band. One hard stroke reads
// as a machined ring; clouds have no edge.
for (const [k, m] of [[1, 0.22], [0.62, 0.3], [0.3, 0.42]]) {
ctx.beginPath();
ctx.arc(ox, oy, r, b.a0 + rot, b.a0 + rot + b.len);
ctx.lineWidth = r * 0.17 * b.w * k;
ctx.lineCap = "round";
ctx.strokeStyle = `rgba(${col},${(a * m).toFixed(3)})`;
ctx.stroke();
}
}
}
// the throat: everything converges on a dark core that grows with `eye`
const core = 10 + eye * eye * Math.hypot(W, H) * 0.6;
const g = ctx.createRadialGradient(cx, cy, core * 0.6, cx, cy, core * 1.35);
g.addColorStop(0, "#03040a"); g.addColorStop(1, "#03040a00");
ctx.fillStyle = g; ctx.fillRect(0, 0, W, H);
// vignette so the edges stay a room, not a rectangle
const v = ctx.createRadialGradient(cx, cy, Math.min(W, H) * 0.35, cx, cy, Math.hypot(W, H) * 0.62);
v.addColorStop(0, "#00000000"); v.addColorStop(1, "#02030780");
ctx.fillStyle = v; ctx.fillRect(0, 0, W, H);
}
/* progress from layout, never from accumulated wheel deltas */
function readProgress() {
const track = scroller.firstElementChild;
const max = track.offsetHeight - scroller.clientHeight;
p = max > 0 ? clamp01(scroller.scrollTop / max) : 0;
stage.style.setProperty("--p1", smooth(slice(p, 0, 0.3)).toFixed(4));
stage.style.setProperty("--p3", smooth(slice(p, 0.86, 1)).toFixed(4));
rp.textContent = p.toFixed(3);
}
let raf = 0;
scroller.addEventListener("scroll", () => { readProgress(); if (!raf) raf = requestAnimationFrame(() => { raf = 0; paint(); }); }, { passive: true });
addEventListener("resize", size);
size(); readProgress();
if (!reduce) (function loop() { paint(); requestAnimationFrame(loop); })();
</script>Where it breaks
- This is a stand-in for a rendered clip, not a recreation of one. The mechanism (sticky stage, layout-derived progress, depth-sorted projection, hero out / chapter in) is what transfers; with a real asset replace the ring loop with
video.currentTime = p * durationand keep everything else. - ~1100 arcs × 3 strokes per frame is fine at 760×400, and fine at 1440×900 on a laptop. On a 4K canvas at DPR 2 it is not; cap the backing store at DPR 2 (done here) and consider dropping the ring count on large stages.
- The eye is opaque black by 0.9, so the next chapter must sit on the same black — a lighter chapter background would flash through the eye's soft edge.
Seen in
@优卓UX上岸社 (Douyin) · 「动效描述」第 3 集 · Void Atlas · 0:06