Web design effects

Scroll-driven character orbit

Scroll walks the camera a full circle around a figure on a pedestal. The title fades as the orbit begins, dark notes surface at the angles they belong to, and the closing line lands as the figure comes back to face you.

How it works

Numbers

Scroll distance
520% of the viewport
Orbit
rotateY(p × 360°), linear · tilt −6° · perspective 900px
Wings
hinged at the spine · ±38°
Title out
0–0.16 · rise 30px · scale 1.1
Note windows
0.18–0.42 · 0.44–0.68 · 0.62–0.86 · edges 6%
Closing line
0.86–0.96

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 character orbit in vanilla HTML/CSS/JS. Scroll container with a 520%-tall track and a sticky stage (height: 100vh, never 100%), light grey radial background. A .cam element at the stage centre with perspective: 900px and perspective-origin 50% 40%, containing a .world with transform-style: preserve-3d and transform: rotateX(-6deg) rotateY(calc(var(--p) * 360deg)). Inside the world, absolutely positioned planes with backface-visibility visible: two 150×300 body planes (inline SVG of a robed figure with a gold crown), the second rotated 90° about y; two 230×300 wing planes (inline SVG feathered wing, the right one mirrored) hinged at the spine with transform-origin on the inner edge and rotateY(−38deg) / rotateY(38deg); a 260px floor disc and a 200px shadow disc laid flat with rotateX(90deg) translateZ(−20px). Read progress p from layout (scrollTop / (trackHeight − clientHeight)) and write --p LINEARLY (no easing). A huge title at the top with opacity calc(1 − var(--p1)) rising 30px and scaling 1.1, where --p1 = smoothstep(slice(p, 0, .16)). Three dark note cards positioned around the stage, each with opacity --o = smoothstep(slice(p, a, a + .06)) × (1 − smoothstep(slice(p, b − .06, b))) for windows (.18,.42), (.44,.68), (.62,.86), rising 16px as they appear. A closing headline bottom-left with --o = smoothstep(slice(p, .86, .96)). Passive scroll listener, one requestAnimationFrame per burst, and a readout showing progress and the orbit angle in degrees.

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-orbit-turntable.html
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-driven character orbit</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: #d6d6d8; color: #111; }
  .scroller { height: 100%; overflow-y: auto; scrollbar-width: thin; }
  .track { height: 520%; }
  /* 🩸 100vh,不是 100%:.track 高 520%,100% 会把舞台撑成整条轨道那么高 */
  .stage { position: sticky; top: 0; height: 100vh; overflow: hidden; background: radial-gradient(80% 70% at 50% 40%, #ececee, #c9c9cc 80%); }
  .nav { position: absolute; left: 4%; right: 4%; top: 12px; display: flex; justify-content: space-between; font-size: 7px; letter-spacing: .1em; text-transform: uppercase; color: #11111199; }
  .nav b { color: #111; }

  /* ---------- the turntable ----------
     The camera never moves. The world rotates about y by p × 360°, and the
     figure is a handful of planes inside it: two crossed body silhouettes
     (so there is no angle at which it goes edge-on), two wing planes hinged
     at the spine, a crown ring, a floor disc. */
  .cam { position: absolute; left: 50%; top: 62%; width: 0; height: 0; perspective: 900px; perspective-origin: 50% 40%; }
  .world { position: absolute; left: 0; top: 0; transform-style: preserve-3d; transform: rotateX(-6deg) rotateY(calc(var(--p) * 360deg)) scale3d(.8, .8, .8); }
  .plane { position: absolute; transform-style: preserve-3d; backface-visibility: visible; }
  .plane svg { position: absolute; left: 0; top: 0; width: 100%; height: 100%; overflow: visible; }
  .body { width: 150px; height: 300px; left: -75px; top: -280px; }
  .body.b2 { transform: rotateY(90deg); }
  .wing { width: 230px; height: 300px; top: -330px; }
  .wing.l { left: -230px; transform-origin: 100% 55%; transform: rotateY(-38deg); }
  .wing.r { left: 0; transform-origin: 0 55%; transform: rotateY(38deg); }
  .floor { width: 260px; height: 260px; left: -130px; top: -130px; transform: rotateX(90deg) translateZ(-20px); border-radius: 50%; background: radial-gradient(circle, #bdbdc2, #bdbdc200 70%); }
  .floor::after { content: ""; position: absolute; inset: 16px; border-radius: 50%; border: 1px dashed #11111133; }
  .shadow { width: 200px; height: 200px; left: -100px; top: -100px; transform: rotateX(90deg) translateZ(-19px); border-radius: 50%; background: radial-gradient(circle, #00000038, #0000 70%); }

  /* ---------- copy ---------- */
  h1 {
    position: absolute; left: 0; right: 0; top: 8%; text-align: center; z-index: 2;
    font-size: 84px; font-weight: 800; letter-spacing: -.03em; line-height: 1; color: #111;
    opacity: calc(1 - var(--p1)); transform: translateY(calc(var(--p1) * -30px)) scale(calc(1 + var(--p1) * .1));
  }
  .note { position: absolute; width: 170px; padding: 12px; background: #1c1c1e; color: #fff; font-size: 7.5px; line-height: 1.6; z-index: 2; opacity: var(--o); transform: translateY(calc((1 - var(--o)) * 16px)); }
  .note small { display: block; font-size: 6px; letter-spacing: .18em; text-transform: uppercase; color: #ffffff80; margin-bottom: 6px; }
  .note.a { left: 4%; top: 30%; }
  .note.b { right: 4%; top: 30%; }
  .note.c { left: 4%; top: 22%; }
  .end {
    position: absolute; left: 6%; bottom: 10%; z-index: 2; font-size: 56px; font-weight: 800; line-height: .9; letter-spacing: -.03em; text-transform: uppercase;
    opacity: var(--o); transform: translateY(calc((1 - var(--o)) * 24px));
  }
  .readout { position: absolute; left: 4%; bottom: 6%; font: 500 10px/1.7 ui-monospace, Menlo, monospace; color: #11111180; z-index: 2; }
  .readout b { color: #111; font-weight: 600; }
  .cue { position: absolute; right: 4%; bottom: 6%; font-size: 10px; letter-spacing: .18em; text-transform: uppercase; color: #11111166; opacity: calc(1 - var(--p1)); }
</style>

<div class="scroller" id="scroller">
  <div class="track">
    <div class="stage" id="stage" style="--p:0;--p1:0">
      <div class="nav"><span><b>Obsidian</b> ◇</span><span>Volume 01 · MMXXV</span><span>Ink, form &amp; modern practice</span><span>☰</span></div>
      <h1>OBSIDIAN</h1>

      <div class="cam"><div class="world">
        <div class="plane floor"></div><div class="plane shadow"></div>
        <div class="plane wing l"><svg viewBox="0 0 170 220" aria-hidden="true"><defs><linearGradient id="w" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fbfaf7"/><stop offset="1" stop-color="#cfcac2"/></linearGradient></defs>
          <path d="M168 120 C120 20 40 0 6 30 C40 40 70 60 90 90 C50 80 20 90 4 120 C40 118 70 128 96 150 C60 160 34 180 24 214 C70 190 120 170 168 150 Z" fill="url(#w)"/>
          <g fill="none" stroke="#b9b3a9" stroke-width="1"><path d="M168 122 C120 70 70 44 26 34"/><path d="M168 136 C120 110 70 108 20 118"/><path d="M168 150 C120 160 80 176 40 204"/></g></svg></div>
        <div class="plane wing r"><svg viewBox="0 0 170 220" aria-hidden="true"><g transform="translate(170 0) scale(-1 1)">
          <path d="M168 120 C120 20 40 0 6 30 C40 40 70 60 90 90 C50 80 20 90 4 120 C40 118 70 128 96 150 C60 160 34 180 24 214 C70 190 120 170 168 150 Z" fill="url(#w)"/>
          <g fill="none" stroke="#b9b3a9" stroke-width="1"><path d="M168 122 C120 70 70 44 26 34"/><path d="M168 136 C120 110 70 108 20 118"/><path d="M168 150 C120 160 80 176 40 204"/></g></g></svg></div>
        <div class="plane body b1"><svg viewBox="0 0 150 300" aria-hidden="true"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#f6f3ee"/><stop offset="1" stop-color="#c9c3b8"/></linearGradient></defs>
          <path d="M75 30 C90 30 100 42 100 58 C100 74 92 84 84 90 L96 100 C120 120 120 180 118 300 L32 300 C30 180 30 120 54 100 L66 90 C58 84 50 74 50 58 C50 42 60 30 75 30 Z" fill="url(#g)"/>
          <path d="M52 34 L52 14 L62 26 L70 6 L78 26 L86 6 L94 26 L98 14 L98 34 Z" fill="#c9a24c"/><rect x="52" y="30" width="46" height="8" rx="2" fill="#b58c3a"/>
          <path d="M60 60 C64 76 86 76 90 60" stroke="#b9a992" stroke-width="2" fill="none"/></svg></div>
        <div class="plane body b2"><svg viewBox="0 0 150 300" aria-hidden="true">
          <path d="M75 30 C90 30 100 42 100 58 C100 74 92 84 84 90 L96 100 C120 120 120 180 118 300 L32 300 C30 180 30 120 54 100 L66 90 C58 84 50 74 50 58 C50 42 60 30 75 30 Z" fill="url(#g)"/>
          <path d="M52 34 L52 14 L62 26 L70 6 L78 26 L86 6 L94 26 L98 14 L98 34 Z" fill="#c9a24c"/><rect x="52" y="30" width="46" height="8" rx="2" fill="#b58c3a"/></svg></div>
      </div></div>

      <div class="note a" id="n1"><small>Premise</small>Here in our studio is a refuge for intention, where patience meets technique and craft dissolves all limits. With global reverence and an unwavering devotion to the singular.</div>
      <div class="note b" id="n2"><small>Process</small>We treat each tattoo as a silent dialogue, a mark that emerges from your story. Your form is a page, and we aspire to inscribe narratives that capture your singularity and essence.</div>
      <div class="note c" id="n3"><small>Practice</small>Obsidian Studio, established by globally acclaimed visionaries of fine line, micro realism and minimalist tattoo, unites the world's finest artists in Brooklyn, NYC.</div>
      <div class="end" id="end">Find<br>your<br>artist</div>

      <div class="readout">progress <b id="rp">0.000</b> · orbit <b id="ro">0°</b></div>
      <div class="cue">scroll ↓</div>
    </div>
  </div>
</div>

<script>
  /* Progress → one angle. Every note owns a window of the orbit and fades in
     and out inside it, so the copy arrives with the side of the figure it
     belongs to and leaves before the next side comes round. */
  const scroller = document.getElementById("scroller"), stage = document.getElementById("stage");
  const rp = document.getElementById("rp"), ro = document.getElementById("ro");
  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);
  const window_ = (p, a, b, e = 0.06) => smooth(slice(p, a, a + e)) * (1 - smooth(slice(p, b - e, b)));
  const NOTES = [["n1", 0.18, 0.42], ["n2", 0.44, 0.68], ["n3", 0.62, 0.86]];
  let raf = 0;
  function update() {
    raf = 0;
    const max = scroller.firstElementChild.offsetHeight - scroller.clientHeight;
    const p = max > 0 ? clamp01(scroller.scrollTop / max) : 0;
    stage.style.setProperty("--p", p.toFixed(4));                       // orbit is linear: no easing on a turntable
    stage.style.setProperty("--p1", smooth(slice(p, 0, 0.16)).toFixed(4));
    for (const [id, a, b] of NOTES) document.getElementById(id).style.setProperty("--o", window_(p, a, b).toFixed(3));
    document.getElementById("end").style.setProperty("--o", smooth(slice(p, 0.86, 0.96)).toFixed(3));
    rp.textContent = p.toFixed(3); ro.textContent = Math.round(p * 360) + "°";
  }
  scroller.addEventListener("scroll", () => { if (!raf) raf = requestAnimationFrame(update); }, { passive: true });
  update();
</script>

Where it breaks

Seen in

@优卓UX上岸社 (Douyin) · 「UI交互动效描述」第 4 集 · Obsidian · 0:25