Web design effects

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

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.

scroll-vortex-transit.html
<!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

Seen in

@优卓UX上岸社 (Douyin) · 「动效描述」第 3 集 · Void Atlas · 0:06