捲動擦洗的影格序列
釘住一屏,用捲動位置挑影格,而不是讓時間軸自己走。沒有任何東西自己在播 —— 讀者就是那個播放進度條,手一停畫面就停。
怎麼做到的
- Scroll position becomes a number in 0…1, and that number picks a frame:
i = floor(p × N). That is the entire effect. Everything below is about making it not stutter. - The progress comes from where the section actually is, never from accumulating
wheeldeltas:p = clamp(-section.getBoundingClientRect().top / (section.scrollHeight − innerHeight), 0, 1). Accumulated deltas drift out of sync with the page the first time someone drags the scrollbar, uses Home/End, or lands on a deep link — and the drift is silent and permanent. - The section is a tall track with a
position: stickystage inside it. It never callspreventDefaulton the wheel: the page scrolls normally the whole time, which is why the scrollbar, keyboard paging and momentum all still behave. - One paint per animation frame, not one per scroll event. A trackpad fires scroll far faster than the display refreshes; painting inline spends the frame budget drawing pictures nobody ever sees. Scroll only records the target index; a
requestAnimationFramecallback draws it. - Repainting is skipped entirely when the index has not changed. With 40 frames across a 460% track, most scroll events resolve to the frame already on screen.
- This sample synthesises each frame on a
<canvas>so the file stays self-contained. With a real asset only the last line changes:ctx.drawImage(images[i], …)for a JPEG sequence, orvideo.currentTime = p * video.durationfor a video. The scroll → progress math above it is identical. - For the
<video>variant: readdurationonly afterloadedmetadata, throttle the seeks to about 24–30 per second, and setmuted playsinline preload="auto"with aposter. Seeking faster than that queues seeks the decoder cannot retire, and the picture lags the scroll by a growing margin.
參數
- Scroll distance
- 460% of the viewport
- Frames
- 40
- Progress
- −rect.top / (scrollHeight − innerHeight)
- Paint budget
- 1 per rAF, skipped if index unchanged
- Video seek rate
- 24–30 /s (video variant)
- Canvas backing
- min(devicePixelRatio, 2)
提示詞
把這段丟給任意一個寫程式的模型,就能從零長出這個效果。它把每個數值都寫死了 ——「絲滑」「現代感」這種詞每次生成出來的都不一樣。
Build a scroll-scrubbed frame sequence in vanilla HTML/CSS/JS. Markup: a scroll container holding a track 460% of the viewport height, with a sticky stage inside it — the stage MUST be height: 100vh, not height: 100%, because 100% resolves against the 460%-tall track. Never call preventDefault on wheel and never accumulate wheel deltaY; derive progress from layout every time: p = clamp(-section.getBoundingClientRect().top / (section.scrollHeight - window.innerHeight), 0, 1). Map it to a frame with i = Math.min(N - 1, Math.floor(p * N)) for N = 40. Register the scroll listener passive; in the handler only store the target index and schedule one requestAnimationFrame; paint inside that callback and return early if the index equals the one already drawn. Back the canvas at min(devicePixelRatio, 2) and rebuild it on resize. If you use a <video> instead of a canvas, set muted, playsinline, preload="auto" and a poster, read duration only after the loadedmetadata event, set currentTime = p * duration, and throttle seeks to 24-30 per second. Show a small monospace readout of progress and frame number so the mechanism is visible. Everything must work when scrolling backwards, when scrolling very fast, and after a window resize.原始碼
整個檔案。上面那個樣板跑的就是它 —— 存成 .html 打開就能用,不需要別的任何東西。
<!doctype html>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Scroll-scrubbed frame sequence</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: #04040a; scrollbar-width: thin; }
.track { height: 460%; }
/* 🩸 100vh,不是 100%:`.track` 高 460%,`height: 100%` 会把舞台撑成
整条轨道那么高,画面居中到看不见的地方去。 */
.stage {
position: sticky; top: 0; height: 100vh;
overflow: hidden; background: #04040a;
}
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.hud { position: absolute; inset: 0; pointer-events: none; color: #fff; }
.eyebrow {
position: absolute; left: 5%; top: 8%;
font-size: 10px; letter-spacing: 0.22em; text-transform: uppercase; color: #ffffff66;
}
/* 把机制摆到台面上:读数就是 scroll → 帧号这条链路本身 */
.readout {
position: absolute; left: 5%; bottom: 9%;
font: 500 11px/1.7 ui-monospace, SFMono-Regular, Menlo, monospace;
letter-spacing: 0.06em; color: #ffffff8c;
}
.readout b { color: #ffd8a8; font-weight: 600; }
.rail {
position: absolute; right: 5%; top: 22%; bottom: 22%; width: 2px;
background: #ffffff1f; border-radius: 2px;
}
.rail i { position: absolute; left: 0; top: 0; width: 100%; background: #ffb066; border-radius: 2px; }
.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">
<canvas id="cv"></canvas>
<div class="hud">
<div class="eyebrow">01 — Sequence</div>
<div class="readout">progress <b id="rp">0.000</b><br>frame <b id="rf">1</b> / 40</div>
<div class="rail"><i id="ri"></i></div>
<div class="cue">scroll ↓</div>
</div>
</div>
</div>
</div>
<script>
const N = 40; // frames in the sequence
const scroller = document.getElementById("scroller");
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
const rp = document.getElementById("rp"), rf = document.getElementById("rf"), ri = document.getElementById("ri");
/* ---------- the sequence ----------
A real build points this at an asset: 40 JPEGs blitted with drawImage, or
one <video> driven by `video.currentTime = progress * video.duration`.
Here each frame is synthesised so the sample stays a single file — the
scroll → index math below is identical either way. */
const rot = (p, ax, ay) => {
let { x, y, z } = p;
let c = Math.cos(ay), s = Math.sin(ay);
[x, z] = [x * c + z * s, -x * s + z * c];
c = Math.cos(ax); s = Math.sin(ax);
[y, z] = [y * c - z * s, y * s + z * c];
return { x, y, z };
};
const F = 620; // focal length
const proj = (p, cx, cy, k) => {
const d = F / (F + p.z);
return { x: cx + p.x * k * d, y: cy + p.y * k * d, d };
};
// A blocky figure: two boxes and four limbs, defined once in body space.
const box = (cx, cy, cz, hx, hy, hz) => ({
v: [[-1,-1,-1],[1,-1,-1],[1,1,-1],[-1,1,-1],[-1,-1,1],[1,-1,1],[1,1,1],[-1,1,1]]
.map(([sx, sy, sz]) => ({ x: cx + sx * hx, y: cy + sy * hy, z: cz + sz * hz })),
f: [[0,1,2,3,0,0,-1],[5,4,7,6,0,0,1],[4,0,3,7,-1,0,0],[1,5,6,2,1,0,0],[4,5,1,0,0,-1,0],[3,2,6,7,0,1,0]],
});
const HEAD = box(0, -63, 0, 16, 16, 16);
const TORSO = box(0, -10, 0, 19, 30, 11);
const LIMBS = [
[{ x: 0, y: -46, z: 0 }, { x: 0, y: -38, z: 0 }, 6], // neck
[{ x: -19, y: -32, z: 0 }, { x: -40, y: -6, z: 10 }, 7], // upper arm L
[{ x: -40, y: -6, z: 10 }, { x: -52, y: 24, z: 24 }, 5.5], // forearm L
[{ x: 19, y: -32, z: 0 }, { x: 44, y: -30, z: -6 }, 7], // upper arm R
[{ x: 44, y: -30, z: -6 }, { x: 62, y: -56, z: 4 }, 5.5], // forearm R
[{ x: -11, y: 17, z: 0 }, { x: -20, y: 54, z: 8 }, 8], // thigh L
[{ x: -20, y: 54, z: 8 }, { x: -14, y: 90, z: 24 }, 6 ], // shin L
[{ x: 11, y: 17, z: 0 }, { x: 24, y: 52, z: -4 }, 8], // thigh R
[{ x: 24, y: 52, z: -4 }, { x: 40, y: 84, z: 8 }, 6], // shin R
];
// Points FROM the surface TOWARDS the key light: up, left, and towards the
// camera (screen y grows downwards, and the camera sits at negative z).
const LIGHT = (() => { const l = { x: -0.48, y: -0.72, z: -0.50 }; const m = Math.hypot(l.x, l.y, l.z); return { x: l.x/m, y: l.y/m, z: l.z/m }; })();
function renderFrame(i, w, h) {
const t = N > 1 ? i / (N - 1) : 0;
ctx.clearRect(0, 0, w, h);
// stage: deep blue room, one warm key light falling from the upper left
const g = ctx.createRadialGradient(w * 0.34, -h * 0.2, 0, w * 0.34, -h * 0.2, h * 1.7);
g.addColorStop(0, "#1b3d8f"); g.addColorStop(0.42, "#0d1636"); g.addColorStop(1, "#04060f");
ctx.fillStyle = g; ctx.fillRect(0, 0, w, h);
// The beam gets a gradient ACROSS its width, not along its length. A flat
// polygon leaves two hard diagonal edges that read as a crease in the wall.
const ax = w * 0.16, bx = w * 0.62;
const dx = bx - ax, dy = h, dl = Math.hypot(dx, dy);
const px = dy / dl, py = -dx / dl; // unit perpendicular
const half = w * 0.19;
const mx = (ax + bx) / 2, my = h / 2;
const beam = ctx.createLinearGradient(mx - px * half, my - py * half, mx + px * half, my + py * half);
beam.addColorStop(0, "rgba(255,206,150,0)");
beam.addColorStop(0.5, "rgba(255,206,150,0.17)");
beam.addColorStop(1, "rgba(255,206,150,0)");
ctx.fillStyle = beam;
ctx.beginPath();
ctx.moveTo(mx - px * half - dx / 2, my - py * half - dy / 2);
ctx.lineTo(mx + px * half - dx / 2, my + py * half - dy / 2);
ctx.lineTo(mx + px * half + dx / 2, my + py * half + dy / 2);
ctx.lineTo(mx - px * half + dx / 2, my - py * half + dy / 2);
ctx.fill();
const yaw = -1.05 + t * 3.6;
const pitch = Math.sin(t * Math.PI * 1.35) * 0.6 - 0.18;
const k = (h / 300) * (1.08 + t * 0.5);
const cx = w * 0.5 + Math.sin(t * Math.PI * 1.1) * w * 0.07;
const cy = h * 0.58 - t * h * 0.17;
const draw = [];
for (const [b, tint] of [[TORSO, 0], [HEAD, 1]]) {
const rv = b.v.map((p) => rot(p, pitch, yaw));
for (const f of b.f) {
const q = [rv[f[0]], rv[f[1]], rv[f[2]], rv[f[3]]];
const n = rot({ x: f[4], y: f[5], z: f[6] }, pitch, yaw);
if (n.z > 0.02) continue; // back face, skip
const lit = Math.max(0, n.x * LIGHT.x + n.y * LIGHT.y + n.z * LIGHT.z);
draw.push({ z: (q[0].z + q[1].z + q[2].z + q[3].z) / 4, kind: "f", q, lit, tint });
}
}
for (const [a, b, r] of LIMBS) {
const p0 = rot(a, pitch, yaw), p1 = rot(b, pitch, yaw);
draw.push({ z: (p0.z + p1.z) / 2, kind: "l", p0, p1, r });
}
draw.sort((m, n) => n.z - m.z); // painter's algorithm
for (const d of draw) {
if (d.kind === "f") {
const pts = d.q.map((p) => proj(p, cx, cy, k));
const v = (d.tint ? 40 : 24) + d.lit * (d.tint ? 205 : 150);
ctx.fillStyle = d.tint
? `rgb(${Math.round(v)},${Math.round(v * 0.88)},${Math.round(v * 0.68)})`
: `rgb(${Math.round(v * 0.86)},${Math.round(v * 0.90)},${Math.round(v * 1.0)})`;
ctx.beginPath(); ctx.moveTo(pts[0].x, pts[0].y);
for (let j = 1; j < 4; j++) ctx.lineTo(pts[j].x, pts[j].y);
ctx.closePath(); ctx.fill();
} else {
const a = proj(d.p0, cx, cy, k), b = proj(d.p1, cx, cy, k);
const wd = d.r * k * ((a.d + b.d) / 2) * 2;
ctx.lineCap = "round";
// A cylinder is a barrel plus a highlight, not a flat stroke. Drawing the
// core once more, thinner and shifted towards the light, is enough to
// stop the limbs reading as noodles.
ctx.strokeStyle = "#4b5470"; ctx.lineWidth = wd;
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke();
const ox = LIGHT.x * wd * 0.22, oy = LIGHT.y * wd * 0.22;
ctx.strokeStyle = "#e8dcc6"; ctx.lineWidth = wd * 0.55;
ctx.beginPath(); ctx.moveTo(a.x + ox, a.y + oy); ctx.lineTo(b.x + ox, b.y + oy); ctx.stroke();
}
}
}
/* ---------- scroll → frame ---------- */
let w = 0, h = 0, shown = -1, pending = 0;
const paint = (i) => {
if (i === shown) return; // the throttle that matters:
shown = i; // never redraw the same frame
renderFrame(i, w, h);
rf.textContent = String(i + 1);
};
const update = () => {
const max = scroller.scrollHeight - scroller.clientHeight;
const p = max > 0 ? Math.min(1, Math.max(0, scroller.scrollTop / max)) : 0;
rp.textContent = p.toFixed(3);
ri.style.height = (p * 100).toFixed(2) + "%";
pending = Math.min(N - 1, Math.floor(p * N));
// One paint per animation frame, not one per scroll event. A trackpad fires
// scroll far faster than the display refreshes; painting inline burns the
// frame budget redrawing pictures nobody ever sees.
if (!raf) raf = requestAnimationFrame(tick);
};
let raf = 0;
const tick = () => { raf = 0; paint(pending); };
const resize = () => {
const dpr = Math.min(2, devicePixelRatio || 1);
w = cv.clientWidth; h = cv.clientHeight;
cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
shown = -1; // size changed, cache is stale
paint(pending);
};
scroller.addEventListener("scroll", update, { passive: true });
addEventListener("resize", () => { resize(); update(); });
resize(); update();
</script>會翻車的地方
- A real video only scrubs smoothly if its keyframe interval is short. Encode at roughly one keyframe every 5–10 frames; a normally-encoded clip has one every few seconds, and every seek in between decodes from the last keyframe forward — it feels like the video is stuck, and the code looks fine.
- iOS Safari will not seek a
<video>that has never been played, and it ignorespreloadon a metered connection. A poster image plus a canvas sequence is the reliable path on phones; the video path needs a real fallback there, not a promise. - Scroll-scrubbed media has no state a reduced-motion user can opt out of — there is no animation to disable, only content that will not appear. Under
prefers-reduced-motion: reduce, pin it to a representative frame and let the section scroll past normally. - 460% of viewport height buys about four thumb-flicks on a phone with nothing else on screen. On small viewports cut the track, not the frame count — a short sequence over a long track just makes each frame linger.
出處
@派大鑫 (Douyin) · “这是我 Vibe Coding 的个人站” · 0:00 & 1:09