---
title: "3D Scroll Storytelling"
summary: "A pinned 3D scene with text scrolling on the right: camera moves per step, parts pop in, labels follow 3D points. Pure three.js."
category: "visual (Charts & 3D)"
origin: "original"
stack: ["html","css","three.js"]
tags: ["3D","scroll storytelling","product intro","clay style"]
demo: "https://zhuoqidev.com/design/demos/scroll-3d-story/"
url: "https://zhuoqidev.com/en/design/scroll-3d-story/"
license: "MIT"
added: "2026-10-14"
translation: "https://zhuoqidev.com/design/scroll-3d-story/index.md"
---

# 3D Scroll Storytelling

> A pinned 3D scene with text scrolling on the right: camera moves per step, parts pop in, labels follow 3D points. Pure three.js.

## What makes it work

- **Pinned stage**: the scene is pinned on the left with `position: sticky`, and on the right each step is a card at least 62vh tall; on phones the stage is pinned at the top and cards scroll past beneath it.
- **Current step = the last card to cross the reading line**: on wide screens the reading line is at 55% of the viewport, on narrow screens a little below the stage; scroll events are coalesced into `requestAnimationFrame`, so it is computed at most once per frame.
- **The camera changes only two numbers**: the camera always looks from the same direction, and each step changes only "where to look" and "how far away", chasing the target with exponential interpolation so switching steps doesn't feel dizzying. Step 0 is the overview, where the scene is first "folded away" and then built up stop by stop.
- **Parts enter with springs**: each stop is a group of parts that pop up from the ground only when that step is reached (a spring with a little overshoot) and retract when you scroll back; skipping several steps at once still builds them in order. The dots along the road appear in order of their distance from the previous stop, and the little letter flies along the road to the current stop.
- **HTML labels attached to 3D points**: each frame the anchors are projected to canvas coordinates and ordinary HTML labels are placed there; a label is hidden when its point leaves the view, placed below if there is no room above, and slid inward when it touches the left or right edge. The text is selectable, readable by screen readers, and no text layout is needed in 3D.
- **Modeled in code**: the desk, envelope, mailbox, mail truck and houses are assembled from rounded boxes, cylinders and triangular prisms, so there are no model files to download; `MeshStandardMaterial` with roughness 0.72 plus soft shadows gives the clay look.
- **Power saving**: it renders only while something is moving and stops the loop when things stop; it also stops when out of the viewport. With reduced motion preferred, the camera and parts go straight to their positions.

## How to use it

1. Copy the `.story-body` structure and styles: on the left `.story-stage > .story-fig` (holding the canvas and the `.pins` label layer), on the right `<ol class="story-steps">`, with one `data-step` per step.
2. Replace the scene in `build()` with your own: make a `Group` for each step and put it into `pieces` (noting which step it belongs to), then give each stop an anchor `Object3D` for the labels.
3. Change the two arrays `FOCUS` / `DIST`: where the camera looks and how far away it is at each step. Step 0 pulls back for the overview.
4. Write labels in HTML: `<span class="label" data-pin="anchor name" data-step="step number">`; the script handles positioning and showing / hiding.
5. If the first screen must be fast, show a static image from the same camera position first, load three.js only after the user's first scroll or click, and fade the canvas in once loaded (the picture doesn't jump).

## Pitfalls

- Read layout (`getBoundingClientRect`) only in the frame right after scrolling; not at load time and not interleaved with style writes, otherwise it forces reflow and scrolling stutters.
- The canvas needs `touch-action: pan-y`, otherwise on phones a vertical swipe on the scene is eaten by the drag and the page can't scroll; dragging is used only to rotate a little left and right, and it rotates back to the front on release.
- Use a small fixed step for the spring integration (1/120 s here), so it doesn't fly off when frames drop.
- At different aspect ratios the overview gets cropped: pull the camera back by "reference aspect ratio ÷ actual aspect ratio".
- Without WebGL the stage shows a notice, and the text steps still tell the whole story; for real use it's best to have a static image as a fallback.

## Reference implementation (full source)

Live demo: https://zhuoqidev.com/design/demos/scroll-3d-story/. One file: save it as .html and open it.

### index.html

```html
<!doctype html>
<html lang="zh-CN">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>3D 滚动叙事</title>
    <style>
      :root {
        --paper: #fbf8f1;
        --paper-2: #f3eee2;
        --card: #fffdf8;
        --ink: #1c2230;
        --ink-2: #4a5163;
        --ink-3: #636877;
        --line: #e6dfd0;
        --dot: #d8d0bf;
        --blue: #2f5bd3;
        --blue-line: rgba(47, 91, 211, 0.35);
        --seal: #c0391f;
        --stamp: #c0391f; /* seal background behind cream text, the same in both themes */
        --ground: #efe8d8; /* the clay board the scene stands on */
        --shadow: 0 1px 0 rgba(28, 34, 48, 0.04), 0 10px 28px -14px rgba(28, 34, 48, 0.28);
        --ease: cubic-bezier(0.16, 1, 0.3, 1);
        --spring: linear(
          0,
          0.062,
          0.215,
          0.411,
          0.615,
          0.799,
          0.95,
          1.059,
          1.126,
          1.158,
          1.161,
          1.145,
          1.116,
          1.083,
          1.051,
          1.023,
          1.001,
          0.986,
          0.977,
          0.974,
          0.974,
          0.978,
          0.983,
          0.988,
          0.994,
          0.998,
          1.001,
          1.003,
          1.004,
          1.004,
          1
        );
        --f-sans: system-ui, -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
        --f-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
        color-scheme: light;
      }
      @media (prefers-color-scheme: dark) {
        :root {
          --paper: #14161b;
          --paper-2: #1c1f26;
          --card: #1e2129;
          --ink: #ece7dc;
          --ink-2: #bab6ac;
          --ink-3: #959aa8;
          --line: #31353f;
          --dot: #2c3039;
          --blue: #8eaaff;
          --blue-line: rgba(142, 170, 255, 0.45);
          --seal: #f0694e;
          --ground: #8d8778;
          --shadow: 0 1px 0 rgba(0, 0, 0, 0.25), 0 10px 28px -14px rgba(0, 0, 0, 0.8);
          color-scheme: dark;
        }
      }
      * {
        box-sizing: border-box;
      }
      body {
        margin: 0;
        background: var(--paper) radial-gradient(var(--dot) 1px, transparent 1.2px) 0 0 / 22px 22px;
        color: var(--ink);
        font: 16px/1.7 var(--f-sans);
      }
      .story {
        max-width: 1240px;
        margin: 0 auto;
        padding: 24px 24px 0;
      }
      .story-head {
        max-width: 760px;
        margin: 0 auto 18px;
        text-align: center;
      }
      .kicker {
        margin: 0;
        font: 500 12px/1.6 var(--f-mono);
        letter-spacing: 0.08em;
        color: var(--seal);
      }
      .story-head h1 {
        margin: 4px 0 4px;
        font-size: 28px;
        line-height: 1.3;
      }
      .story-sub {
        margin: 0;
        font-size: 14.5px;
        color: var(--ink-2);
      }

      /* ---------- the pinned stage and the steps beside it ---------- */
      .story-body {
        display: grid;
        grid-template-columns: minmax(0, 1fr) 330px;
        gap: 36px;
      }
      .story-stage {
        position: sticky;
        top: 16px;
        align-self: start;
      }
      .story-fig {
        position: relative;
        margin: 0 auto;
        overflow: hidden;
        width: min(100%, calc((100svh - 40px) * 1.6));
        aspect-ratio: 16 / 10;
        background: var(--card);
        border: 1px solid var(--line);
        border-radius: 14px;
        box-shadow: var(--shadow);
      }
      .story-fig canvas {
        position: absolute;
        inset: 0;
        width: 100%;
        height: 100%;
        touch-action: pan-y; /* a vertical swipe still scrolls the page */
        cursor: grab;
      }
      .story-fig canvas:active {
        cursor: grabbing;
      }
      .nogl {
        display: grid;
        place-items: center;
        height: 100%;
        margin: 0;
        padding: 24px;
        text-align: center;
        color: var(--ink-3);
      }
      .pins {
        position: absolute;
        inset: 0;
        pointer-events: none;
      }
      /* Labels hang from a point projected out of the 3D scene; the script moves left/top every frame. */
      .label,
      .num {
        position: absolute;
        visibility: hidden;
        opacity: 0;
        transition:
          opacity 0.25s,
          visibility 0.25s;
      }
      .label.on,
      .num.on {
        visibility: visible;
        opacity: 1;
      }
      .label.out,
      .num.out {
        visibility: hidden;
        opacity: 0;
      }
      .num {
        translate: -50% -50%;
        display: grid;
        place-items: center;
        width: 26px;
        height: 26px;
        border-radius: 50%;
        background: var(--stamp);
        color: #fff8ef;
        font: 700 13px/1 var(--f-mono);
        box-shadow: 0 0 0 3px var(--card);
      }
      .label {
        width: 0;
        height: 0;
      }
      .label::before {
        content: "";
        position: absolute;
        left: -1px;
        bottom: 0;
        width: 2px;
        height: 20px;
        background: var(--ink);
      }
      .label::after {
        content: "";
        position: absolute;
        left: -5px;
        top: -5px;
        width: 10px;
        height: 10px;
        border-radius: 50%;
        background: var(--seal);
        box-shadow: 0 0 0 2px var(--card);
      }
      .label > span {
        position: absolute;
        bottom: 20px;
        left: 0;
        translate: calc(-50% + var(--dx, 0px)) 0;
        padding: 4px 10px;
        white-space: nowrap;
        border: 1.5px solid var(--ink);
        border-radius: 10px;
        background: var(--card);
        color: var(--ink);
        font: 500 13px/1.45 var(--f-sans);
        box-shadow: 2px 3px 0 rgba(28, 34, 48, 0.18);
      }
      .label.below::before {
        bottom: auto;
        top: 0;
      }
      .label.below > span {
        bottom: auto;
        top: 20px;
      }
      .story-steps {
        list-style: none;
        margin: 0;
        padding: 0 0 30vh;
      }
      .story-step {
        min-height: 62vh;
      }
      .story-card {
        position: relative;
        padding: 18px 20px 18px 56px;
        background: var(--card);
        border: 2px dashed var(--blue-line);
        border-radius: 14px;
        box-shadow: var(--shadow);
        transition:
          border-color 0.3s,
          translate 0.5s var(--spring);
      }
      .story-card h2 {
        margin: 0 0 6px;
        font-size: 18px;
        line-height: 1.45;
      }
      .story-card p {
        margin: 0;
        font-size: 14.5px;
        line-height: 1.8;
        color: var(--ink-2);
      }
      .story-n {
        position: absolute;
        left: 16px;
        top: 18px;
        display: grid;
        place-items: center;
        width: 27px;
        height: 27px;
        border-radius: 50%;
        border: 1.5px solid var(--ink-3);
        font: 600 13px/1 var(--f-mono);
        color: var(--ink-2);
        transition:
          background-color 0.3s,
          color 0.3s,
          border-color 0.3s;
      }
      .story-step.on .story-card {
        border-style: solid;
        border-color: var(--seal);
        translate: -6px 0;
      }
      .story-step.on .story-n {
        background: var(--stamp);
        border-color: var(--stamp);
        color: #fff8ef;
      }
      @media (max-width: 820px) {
        .story {
          padding: 20px 16px 0;
        }
        .story-head h1 {
          font-size: 24px;
        }
        .story-body {
          display: block;
        }
        /* On a phone the stage rides along at the top and the steps scroll beneath it. */
        .story-stage {
          top: 0;
          z-index: 3;
          margin: 0 -16px;
          padding: 8px 16px 10px;
          background: var(--paper);
        }
        .story-fig {
          width: 100%;
        }
        .story-step {
          min-height: 52svh;
        }
        .story-step:first-child {
          padding-top: 14px;
        }
        .label > span {
          font-size: 12.5px;
          padding: 3px 8px;
        }
        .story-step.on .story-card {
          translate: none;
        }
      }
      @media (prefers-reduced-motion: reduce) {
        .story-card {
          transition: border-color 0.3s;
        }
      }
    </style>
  </head>
  <body>
    <section class="story" data-story>
      <header class="story-head">
        <p class="kicker">SCROLL STORY · 示例</p>
        <h1>一封信是怎么寄到的</h1>
        <p class="story-sub">往下滚，场景跟着一步步搭起来；拖动场景可以左右转着看。</p>
      </header>
      <div class="story-body">
        <div class="story-stage">
          <figure class="story-fig" data-fig>
            <div class="pins" aria-hidden="true">
              <span class="num" data-pin="s1" data-step="0">1</span>
              <span class="num" data-pin="s2" data-step="0">2</span>
              <span class="num" data-pin="s3" data-step="0">3</span>
              <span class="num" data-pin="s4" data-step="0">4</span>
              <span class="num" data-pin="s5" data-step="0">5</span>
              <span class="label" data-pin="s1" data-step="1"><span>写好的信</span></span>
              <span class="label" data-pin="s2" data-step="2"><span>邮票 = 邮资已付</span></span>
              <span class="label" data-pin="s3" data-step="3"><span>邮筒</span></span>
              <span class="label" data-pin="s4" data-step="4"><span>按地址分拣、装车</span></span>
              <span class="label" data-pin="s5" data-step="5"><span>收件人的信箱</span></span>
            </div>
          </figure>
        </div>
        <ol class="story-steps">
          <li class="story-step" data-step="0">
            <div class="story-card">
              <span class="story-n" aria-hidden="true">↓</span>
              <h2>从桌上到门口</h2>
              <p>图里是一封信要走的全程：五站，一条路。往下滚，场景会先收起来，再一站一站重新搭出来。</p>
            </div>
          </li>
          <li class="story-step" data-step="1">
            <div class="story-card">
              <span class="story-n" aria-hidden="true">1</span>
              <h2>写好一封信</h2>
              <p>一张纸、一支笔。写完折好，这时它还只是桌上的一张纸。</p>
            </div>
          </li>
          <li class="story-step" data-step="2">
            <div class="story-card">
              <span class="story-n" aria-hidden="true">2</span>
              <h2>装进信封，贴上邮票</h2>
              <p>正面写清收件地址，右上角贴邮票：地址决定它去哪，邮票说明路费已经付过。</p>
            </div>
          </li>
          <li class="story-step" data-step="3">
            <div class="story-card">
              <span class="story-n" aria-hidden="true">3</span>
              <h2>投进邮筒</h2>
              <p>邮筒每天定时开箱。信被收走之前，就在里面和别的信一起等着。</p>
            </div>
          </li>
          <li class="story-step" data-step="4">
            <div class="story-card">
              <span class="story-n" aria-hidden="true">4</span>
              <h2>分拣、装车</h2>
              <p>邮局按地址把信分好，去同一个方向的装上同一辆车，一路转运到收件人所在的地方。</p>
            </div>
          </li>
          <li class="story-step" data-step="5">
            <div class="story-card">
              <span class="story-n" aria-hidden="true">5</span>
              <h2>送到门口</h2>
              <p>投递员把信放进收件人门口的信箱，这一程结束。往回滚，可以再看一遍。</p>
            </div>
          </li>
        </ol>
      </div>
    </section>

    <script type="importmap">
      {
        "imports": {
          "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
          "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.186.1/examples/jsm/"
        }
      }
    </script>
    <script type="module">
      import {
        BoxGeometry,
        CatmullRomCurve3,
        Color,
        CylinderGeometry,
        DirectionalLight,
        ExtrudeGeometry,
        Group,
        HemisphereLight,
        Mesh,
        MeshStandardMaterial,
        NeutralToneMapping,
        Object3D,
        PCFShadowMap,
        PerspectiveCamera,
        PMREMGenerator,
        Scene,
        Shape,
        SphereGeometry,
        SRGBColorSpace,
        Vector3,
        WebGLRenderer,
      } from "three";
      import { RoomEnvironment } from "three/addons/environments/RoomEnvironment.js";
      import { RoundedBoxGeometry } from "three/addons/geometries/RoundedBoxGeometry.js";

      const root = document.querySelector("[data-story]");
      const fig = root.querySelector("[data-fig]");
      const steps = [...root.querySelectorAll(".story-step")];
      const pinned = [...fig.querySelectorAll("[data-pin]")];
      const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
      const wide = matchMedia("(min-width: 821px)");
      const LAST = steps.length - 1;

      // =====================================================================================
      // The model: five stations on a clay board, built from primitives (no model file).
      // Step 0 is the overview (everything shown); step k (1..5) shows stations 1..k.
      // =====================================================================================
      const HEX = {
        ink: "#1c2230",
        seal: "#d24a30",
        blue: "#4f74d9",
        green: "#4f9a6c",
        yellow: "#f6d453",
        cream: "#fbf6ea",
        white: "#ffffff",
        wood: "#c99a6a",
        grey: "#a3a8b4",
      };
      const mats = new Map();
      const mat = (key) => {
        if (!mats.has(key))
          mats.set(key, new MeshStandardMaterial({ color: HEX[key] ?? key, roughness: 0.72, metalness: 0 }));
        return mats.get(key);
      };
      const box = (w, h, d, r = 0.04) => new RoundedBoxGeometry(w, h, d, 2, Math.min(r, w / 2, h / 2, d / 2));
      const cyl = (r, h, n = 24) => new CylinderGeometry(r, r, h, n);
      /** Adds a mesh to `parent` at [x, y, z] (y up) with an optional rotation. */
      const put = (parent, geo, color, [x, y, z], rot = [0, 0, 0]) => {
        const m = new Mesh(geo, mat(color));
        m.position.set(x, y, z);
        m.rotation.set(...rot);
        m.castShadow = m.receiveShadow = true;
        parent.add(m);
        return m;
      };
      const prism = (w, h, depth) => {
        const s = new Shape();
        s.moveTo(-w / 2, 0);
        s.lineTo(w / 2, 0);
        s.lineTo(0, h);
        s.closePath();
        return new ExtrudeGeometry(s, { depth, bevelEnabled: false }).translate(0, 0, -depth / 2);
      };

      // Where each station stands, and the point its label hangs from (relative to the station).
      const STATIONS = [
        { at: new Vector3(-5.0, 0, 0.6), pin: new Vector3(-0.15, 0.9, 0) },
        { at: new Vector3(-2.5, 0, -0.9), pin: new Vector3(0.6, 1.25, 0.3) },
        { at: new Vector3(0.1, 0, 0.7), pin: new Vector3(0, 1.8, 0) },
        { at: new Vector3(2.8, 0, -0.8), pin: new Vector3(-0.2, 1.3, 0) },
        { at: new Vector3(5.3, 0, 0.5), pin: new Vector3(0.95, 1.0, 0.55) },
      ];

      function station1(g) {
        // a desk with a letter and a pen on it
        put(g, box(1.8, 0.1, 1.1), "wood", [0, 0.8, 0]);
        for (const [x, z] of [
          [-0.8, -0.45],
          [0.8, -0.45],
          [-0.8, 0.45],
          [0.8, 0.45],
        ])
          put(g, cyl(0.05, 0.8, 12), "wood", [x, 0.4, z]);
        put(g, box(0.75, 0.02, 0.95, 0.01), "white", [-0.15, 0.86, 0.02], [0, 0.12, 0]);
        for (let i = 0; i < 5; i++)
          put(
            g,
            box(i === 4 ? 0.3 : 0.52, 0.012, 0.035, 0.005),
            "grey",
            [-0.17 - (i === 4 ? 0.11 : 0), 0.875, -0.3 + i * 0.13],
            [0, 0.12, 0],
          );
        put(g, cyl(0.03, 0.62, 12), "blue", [0.55, 0.88, 0.05], [Math.PI / 2, 0, 0.5]);
      }
      function station2(g) {
        // an envelope on a little stand, tilted towards the reader: flap, stamp and address lines
        put(g, box(0.9, 0.12, 0.4), "wood", [0, 0.06, 0]);
        const env = new Group();
        env.position.set(0, 0.12, 0);
        env.rotation.x = -0.28;
        g.add(env);
        put(env, box(1.7, 1.08, 0.06, 0.03), "cream", [0, 0.56, 0]);
        put(env, prism(1.62, 0.5, 0.02), "white", [0, 1.07, 0.035], [0, 0, Math.PI]);
        put(env, box(0.26, 0.32, 0.03, 0.01), "seal", [0.6, 0.86, 0.045]);
        for (let i = 0; i < 3; i++)
          put(env, box(0.7 - i * 0.12, 0.04, 0.02, 0.01), "ink", [-0.18 - i * 0.06, 0.42 - i * 0.12, 0.04]);
      }
      function station3(g) {
        // a mailbox: body, domed top, slot, base
        put(g, cyl(0.5, 0.12), "ink", [0, 0.06, 0]);
        put(g, cyl(0.42, 1.25), "green", [0, 0.74, 0]);
        put(g, new SphereGeometry(0.42, 32, 16, 0, Math.PI * 2, 0, Math.PI / 2), "green", [0, 1.36, 0]);
        put(g, box(0.5, 0.07, 0.1, 0.02), "ink", [0, 1.12, 0.38]);
        put(g, box(0.3, 0.2, 0.04, 0.02), "cream", [0, 0.72, 0.42]);
      }
      function station4(g) {
        // a mail truck with a few parcels waiting beside it
        const t = new Group();
        t.rotation.y = 0.35;
        g.add(t);
        put(t, box(1.5, 0.95, 0.95, 0.08), "cream", [-0.25, 0.75, 0]);
        put(t, box(1.52, 0.1, 0.97, 0.03), "blue", [-0.25, 0.62, 0]);
        put(t, box(0.6, 0.72, 0.9, 0.1), "blue", [0.85, 0.63, 0]);
        put(t, box(0.05, 0.3, 0.7, 0.02), "ink", [1.15, 0.78, 0]);
        for (const [x, z] of [
          [-0.6, 0.48],
          [0.7, 0.48],
          [-0.6, -0.48],
          [0.7, -0.48],
        ])
          put(t, cyl(0.2, 0.14), "ink", [x, 0.2, z], [Math.PI / 2, 0, 0]);
        put(g, box(0.36, 0.3, 0.36), "wood", [-0.6, 0.15, 1.05]);
        put(g, box(0.3, 0.24, 0.3), "wood", [-0.2, 0.12, 1.15]);
        put(g, box(0.26, 0.22, 0.26), "wood", [-0.55, 0.41, 1.05]);
      }
      function station5(g) {
        // a house, and its letter box on a post
        put(g, box(1.4, 1.05, 1.15, 0.05), "cream", [0, 0.53, -0.2]);
        put(g, prism(1.65, 0.75, 1.35), "seal", [0, 1.05, -0.2]);
        put(g, box(0.36, 0.62, 0.05, 0.03), "blue", [-0.2, 0.31, 0.38]);
        put(g, box(0.3, 0.26, 0.05, 0.03), "white", [0.35, 0.62, 0.38]);
        put(g, cyl(0.04, 0.75, 12), "wood", [0.95, 0.38, 0.55]);
        put(g, box(0.36, 0.26, 0.5, 0.06), "yellow", [0.95, 0.86, 0.55]);
        put(g, box(0.04, 0.2, 0.04, 0.01), "seal", [1.15, 0.98, 0.5]);
      }

      function build() {
        const model = new Group();
        const board = put(model, box(13.6, 0.3, 5.0, 0.12), "#efe8d8", [0, -0.15, 0]);
        board.castShadow = false;
        // The route: a dotted path from station to station, in front of them.
        const route = new CatmullRomCurve3([
          new Vector3(-6.3, 0.01, 1.6),
          ...STATIONS.map((s) => s.at.clone().add(new Vector3(0, 0.01, 1.05))),
          new Vector3(6.4, 0.01, 1.7),
        ]);
        const pieces = [];
        const groups = [station1, station2, station3, station4, station5].map((make, i) => {
          const g = new Group();
          g.position.copy(STATIONS[i].at);
          make(g);
          model.add(g);
          pieces.push({ g, step: i + 1, delay: 0.15 });
          return g;
        });
        // A station's u along the route, found by sampling (the route bends, so u is not linear in x).
        const uOf = (p) => {
          let best = 0;
          let bestD = Infinity;
          for (let k = 0; k <= 400; k++) {
            const d = route.getPointAt(k / 400).distanceToSquared(p);
            if (d < bestD) [best, bestD] = [k / 400, d];
          }
          return best;
        };
        const U = STATIONS.map((s) => uOf(s.at.clone().add(new Vector3(0, 0.01, 1.05))));
        const dotGeo = cyl(0.07, 0.03, 12);
        const N = 70;
        for (let k = 0; k <= N; k++) {
          const u = k / N;
          const step = Math.max(1, U.findIndex((x) => x >= u) + 1 || 5); // dots up to station k come with step k
          const d = new Group();
          d.position.copy(route.getPointAt(u));
          put(d, dotGeo, step === 1 ? "grey" : "seal", [0, 0.015, 0]);
          model.add(d);
          const from = step === 1 ? 0 : U[step - 2];
          pieces.push({ g: d, step, delay: 0.35 + Math.max(0, u - from) * 2.2 });
        }
        // The letter that travels: a small envelope with a stamp, riding above the route.
        const letter = new Group();
        put(letter, box(0.5, 0.32, 0.04, 0.02), "white", [0, 0, 0]);
        put(letter, box(0.1, 0.12, 0.02, 0.01), "seal", [0.15, 0.06, 0.03]);
        model.add(letter);
        // Pins for the HTML labels.
        const pins = STATIONS.map((s, i) => {
          const o = new Object3D();
          o.position.copy(s.at).add(s.pin);
          o.name = `s${i + 1}`;
          model.add(o);
          return o;
        });
        return { model, board, route, U, pieces, letter, pins, groups };
      }

      // =====================================================================================
      // The scene: camera per step, pieces that pop in on springs, the letter on the route.
      // =====================================================================================
      function mount() {
        const canvas = document.createElement("canvas");
        canvas.setAttribute("aria-hidden", "true");
        // Throws without WebGL; the caller then keeps the text steps only.
        const renderer = new WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "low-power" });
        renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
        renderer.outputColorSpace = SRGBColorSpace;
        renderer.toneMapping = NeutralToneMapping;
        renderer.shadowMap.enabled = true;
        renderer.shadowMap.type = PCFShadowMap;

        const scene = new Scene();
        const pmrem = new PMREMGenerator(renderer);
        scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
        scene.environmentIntensity = 0.3;
        pmrem.dispose();
        scene.add(new HemisphereLight(0xffffff, 0x8a8478, 0.35));
        const key = new DirectionalLight(0xffffff, 1.6);
        key.position.set(-5, 12, 8);
        key.castShadow = true;
        key.shadow.mapSize.set(2048, 2048);
        Object.assign(key.shadow.camera, { left: -9, right: 9, top: 9, bottom: -9, near: 1, far: 40 });
        key.shadow.radius = 6;
        key.shadow.intensity = 0.55;
        key.shadow.bias = -0.0005;
        key.shadow.normalBias = 0.03;
        scene.add(key, key.target);

        const { model, board, route, U, pieces, letter, pins } = build();
        scene.add(model);
        for (const p of pieces) Object.assign(p, { s: 1, v: 0, to: 1, wait: 0 });

        // Camera: always looks from the same direction; each step sets where it looks and how far
        // away it stands. Step 0 frames the whole board.
        const DIR = new Vector3(0.32, 1.0, 1.45).normalize();
        const REF_ASPECT = 1.6;
        const FOCUS = [
          new Vector3(0.2, 0.3, -0.4),
          ...STATIONS.map((s) => s.at.clone().add(new Vector3(0, 0.75, 0.3))),
        ];
        const DIST = [18.6, 7.2, 7.2, 7.4, 7.6, 7.8];
        const camera = new PerspectiveCamera(30, REF_ASPECT, 0.5, 100);
        const goalDist = (k) => DIST[k] * Math.max(1, REF_ASPECT / camera.aspect) ** 0.8;
        const look = FOCUS[0].clone();
        let dist = DIST[0];
        let step = 0;
        let yaw = 0;

        // The letter rides to the current station (in the overview it rests between 3 and 4).
        const letterU = (k) => (k === 0 ? (U[2] + U[3]) / 2 : U[k - 1]);
        let lu = letterU(0);
        let luFrom = lu;
        const UPV = new Vector3(0, 1, 0);

        let drag = null;
        canvas.addEventListener("pointerdown", (e) => {
          drag = { x: e.clientX, yaw };
          canvas.setPointerCapture(e.pointerId);
          kick();
        });
        canvas.addEventListener("pointermove", (e) => {
          if (!drag) return;
          yaw = Math.max(-1.1, Math.min(1.1, drag.yaw - (e.clientX - drag.x) * 0.008));
          kick();
        });
        const release = () => {
          drag = null;
          kick();
        };
        canvas.addEventListener("pointerup", release);
        canvas.addEventListener("pointercancel", release);

        const advance = (dt) => {
          let busy = drag !== null;
          const t = reduced ? 1 : 1 - Math.exp(-dt * 3.2);
          look.lerp(FOCUS[step], t);
          dist += (goalDist(step) - dist) * t;
          if (look.distanceToSquared(FOCUS[step]) > 1e-5 || Math.abs(goalDist(step) - dist) > 1e-3) busy = true;
          if (!drag) {
            // let go and the view swings back to the front
            yaw *= reduced ? 0 : Math.exp(-dt * 4);
            if (Math.abs(yaw) > 1e-4) busy = true;
            else yaw = 0;
          }
          for (const p of pieces) {
            if (p.s === p.to && p.v === 0) continue;
            busy = true;
            if (reduced) {
              p.s = p.to;
              p.v = 0;
            } else if (p.wait > 0) {
              p.wait -= dt;
              continue;
            } else {
              // A spring: a small overshoot on the way in, none on the way out.
              const damping = p.to ? 15 : 28;
              p.v += (170 * (p.to - p.s) - damping * p.v) * dt;
              p.s += p.v * dt;
              if (Math.abs(p.to - p.s) < 0.002 && Math.abs(p.v) < 0.02) {
                p.s = p.to;
                p.v = 0;
              }
            }
            p.g.visible = p.s > 0.002;
            p.g.scale.setScalar(Math.max(p.s, 0.002));
          }
          const goal = letterU(step);
          const lt = reduced ? 1 : 1 - Math.exp(-dt * 2.4);
          lu += (goal - lu) * lt;
          if (Math.abs(goal - lu) > 1e-4) busy = true;
          else lu = goal;
          return busy;
        };

        const v = new Vector3();
        const sizes = new Map(); // label element -> [width, height], measured once
        let figW = 0;
        let figH = 0;
        const draw = () => {
          camera.position.copy(look).addScaledVector(v.copy(DIR).applyAxisAngle(UPV, yaw), dist);
          camera.lookAt(look);
          // the letter hops while it travels, then rests a little above the route
          const span = Math.abs(letterU(step) - luFrom) || 1;
          const along = 1 - Math.abs(letterU(step) - lu) / span;
          route.getPointAt(Math.min(1, Math.max(0, lu)), letter.position);
          letter.position.y = 0.45 + Math.sin(Math.PI * along) * 0.6;
          letter.visible = step !== 1; // in step 1 it is still the sheet on the desk
          letter.lookAt(v.copy(camera.position).setY(letter.position.y));
          renderer.render(scene, camera);
          // Put each label on its pin's projected point, or hide it when the point is off screen.
          // All reads (label sizes, once per label) come before any write, so this never forces a layout.
          for (const el of pinned)
            if (el.classList.contains("label") && el.classList.contains("on") && !sizes.has(el))
              sizes.set(el, [el.firstElementChild.offsetWidth, el.firstElementChild.offsetHeight]);
          for (const el of pinned) {
            pins[+el.dataset.pin.slice(1) - 1].getWorldPosition(v).project(camera);
            const inside = v.z < 1 && Math.abs(v.x) < 0.98 && Math.abs(v.y) < 0.98;
            el.classList.toggle("out", !inside);
            if (!inside) continue;
            const x = ((v.x + 1) / 2) * figW;
            const y = ((1 - v.y) / 2) * figH;
            el.style.left = `${x}px`;
            el.style.top = `${y}px`;
            const size = el.classList.contains("on") && sizes.get(el);
            if (!size) continue;
            // hang above the point; with no room above, below it; slide sideways to stay inside
            const [bw, bh] = size;
            el.classList.toggle("below", y < bh + 30);
            const dx = Math.min(Math.max(0, bw / 2 + 8 - x), figW - bw / 2 - 8 - x);
            el.style.setProperty("--dx", `${dx}px`);
          }
        };

        // Render only while something moves; small fixed steps keep the springs in real time.
        let raf = 0;
        let then = 0;
        const tick = (now) => {
          raf = 0;
          let dt = Math.min((now - then) / 1000, 0.25);
          then = now;
          let busy = false;
          while (dt > 0) {
            const h = Math.min(dt, 1 / 120);
            busy = advance(h);
            dt -= h;
          }
          draw();
          if (busy) raf = requestAnimationFrame(tick);
        };
        function kick() {
          if (raf || !seen) return;
          then = performance.now();
          raf = requestAnimationFrame(tick);
        }
        let seen = true;
        new IntersectionObserver(([e]) => {
          seen = e.isIntersecting;
          if (!seen && raf) {
            cancelAnimationFrame(raf);
            raf = 0;
          }
          kick();
        }).observe(fig);

        const theme = () => {
          board.material = new MeshStandardMaterial({
            color: new Color(getComputedStyle(document.documentElement).getPropertyValue("--ground").trim()),
            roughness: 0.8,
          });
          kick();
        };
        matchMedia("(prefers-color-scheme: dark)").addEventListener("change", theme);
        theme();

        new ResizeObserver(([entry]) => {
          figW = entry.contentRect.width;
          figH = entry.contentRect.height;
          sizes.clear(); // fonts may have changed size too
          if (!figW || !figH) return;
          renderer.setSize(figW, figH, false);
          camera.aspect = figW / figH;
          camera.updateProjectionMatrix();
          dist = goalDist(step);
          draw();
          kick();
        }).observe(fig);

        fig.prepend(canvas);
        return {
          setStep(k) {
            if (k === step) return;
            const from = step;
            luFrom = lu;
            step = k;
            for (const p of pieces) {
              const to = k === 0 || p.step <= k ? 1 : 0;
              if (to === p.to) continue;
              p.to = to;
              // Jumping several steps at once still builds them in order.
              p.wait = to ? p.delay + Math.max(0, p.step - Math.max(from, 1) - 1) * 0.3 : 0;
            }
            kick();
          },
        };
      }

      // =====================================================================================
      // Scroll: the current step is the last card that has crossed the reading line.
      // =====================================================================================
      let scene = null;
      try {
        scene = mount();
      } catch {
        const p = document.createElement("p");
        p.className = "nogl";
        p.textContent = "这个浏览器没开 WebGL，3D 场景画不出来；右边的文字照样讲完整个过程。";
        fig.replaceChildren(p);
      }
      let active = -1;
      const show = (next) => {
        if (next === active) return;
        active = next;
        steps.forEach((s, i) => s.classList.toggle("on", i === active));
        for (const el of pinned) el.classList.toggle("on", Number(el.dataset.step) === active);
        scene?.setStep(active);
      };
      const update = () => {
        const figBottom = fig.getBoundingClientRect().bottom;
        // mid-screen on wide screens; a little below the pinned stage on narrow ones
        const line = wide.matches ? innerHeight * 0.55 : figBottom + (innerHeight - figBottom) * 0.4;
        let next = 0;
        steps.forEach((s, i) => {
          if (s.firstElementChild.getBoundingClientRect().top < line) next = i;
        });
        show(next);
      };
      let queued = false;
      const onScroll = () => {
        if (queued) return;
        queued = true;
        requestAnimationFrame(() => {
          queued = false;
          update();
        });
      };
      addEventListener("scroll", onScroll, { passive: true });
      addEventListener("resize", onScroll);
      update();
    </script>
  </body>
</html>
```
