futr
Motion · Video · Performance · Development · iOS · from Spoormaker build

Driving a 45-second cinematic from scroll position

Why we rendered the Spoormaker hero to video rather than building it in WebGL, and the three traps in scrubbing a clip from scroll — each of which fails silently, and only on hardware you do not have on your desk.

Published 2026-08-12 · 9 min read

Image · insight-scroll-driven-video · 16:9

Lead image for “Driving a 45-second cinematic from scroll position”. Prefer a real artefact over an illustration — a screen, a diagram, a terminal, a rendered frame from the build being described. Dark ground, one accent, no stock metaphors.

Save as /public/img/insight-scroll-driven-video.webp

The brief was a hero that assembles a building services system as you scroll into it — plant room, risers, ducting, resolving into a single held frame. The obvious way to build that is WebGL: model the thing, render it live, drive the camera from scroll position. We did not do that, and the reason is worth stating before any of the technique, because it is the decision that mattered most.

A rendered video is deterministic. It looks identical on a six-year-old Android and a new MacBook, it costs nothing to run beyond decoding, and the thing you approved in review is exactly the thing that ships. A live 3D scene is none of those. It negotiates with whatever GPU it lands on, it drains battery, it has a loading state, and it has failure modes that only appear on hardware you do not own. For a hero whose entire job is to make an engineering firm look precise, "renders differently depending on your laptop" is the wrong trade.

So: render the sequence offline, then scrub it with scroll. Conceptually that is four lines of code. In practice it is three traps that each look like a broken video, and none of which are documented in the place you would look.

The whole problem is one property

Scrubbing a video means setting <code>currentTime</code> from scroll position. Everything hard about this follows from the fact that setting <code>currentTime</code> is a request, not an instruction. The browser may honour it immediately, may honour it approximately, may queue it behind a decode, or may quietly clamp it to zero. It reports success either way.

What you seeWhat is actually happening
Video sits on frame one, never movesSeeks are being clamped — the response is not byte-range seekable
Smooth on desktop, freezes on a fast phone flickSeek requests are piling up faster than the decoder retires them
Black rectangle on iPhone until you tapSafari will not paint a seeked frame on a video that has never played
Correct but stuttering on mid-range AndroidKeyframe interval too wide — every seek is decoding a long GOP

Trap one: seekable is not guaranteed

Setting <code>currentTime</code> only works if the server serves byte ranges. Plenty of hosts, CDNs and object stores do not, or do so inconsistently, and the browser then reports the video's seekable range as <code>[0, 0]</code>. Every seek clamps to the first frame. The video element is not broken and throws no error — it is doing exactly what a non-seekable resource permits.

The fix is to stop depending on the host. Fetch the clip as a Blob, create an object URL, and point the video element at that. An in-memory blob is always fully seekable regardless of what the origin does with range requests. You trade a slightly later first paint for a guarantee, and for a hero clip of this size that is the right way round.

Trap two: seeks must be coalesced

The naive implementation sets <code>currentTime</code> on every scroll event. On a desktop trackpad that is fine. On a phone, a fast flick generates scroll events far quicker than the decoder can retire seeks, and each new assignment interrupts the one in progress. The clip appears to freeze, then jumps, then freezes again — and it only reproduces on touch hardware, which is why it survives desktop QA.

The pattern that fixes it is small and non-obvious: never issue a seek while one is outstanding. Hold the latest desired time in a variable, and when the <code>seeked</code> event fires, compare where you are against where you now want to be and issue one more seek only if the gap is worth it. The decoder is never given more than one instruction at a time, and the user gets the newest position rather than a queue of stale ones.

Two details matter. Read scroll position inside <code>requestAnimationFrame</code> rather than in the scroll handler, so you measure once per frame instead of once per event. And apply a small tolerance — we use forty milliseconds — before re-seeking, or the two ends of the comparison chase each other indefinitely on a clip whose frames do not land exactly where you asked.

Trap three: iOS will not paint what it has not played

This is the one that costs a day. On iOS Safari, a muted video that has never been played will accept a <code>currentTime</code> assignment, fire <code>seeked</code>, report the correct time — and paint nothing. You get a black rectangle while every piece of instrumentation tells you the video is working.

Two things together solve it. Hold a poster image over the video until the first real frame is confirmed painted, so the section never shows black. And prime the element on the first user interaction anywhere on the page — call <code>play()</code> and immediately <code>pause()</code> — which satisfies Safari's requirement that the element has been played without the user ever seeing playback. Both are cheap. Neither is discoverable without hitting the problem.

Mobile is a different clip, not a crop

A 21:9 hero centre-cropped to a phone viewport throws away most of the composition and usually the subject with it. We serve a natively framed portrait clip on phones instead — same sequence, recomposed and re-rendered for 9:16 rather than cropped from the landscape master.

That doubles the render and encode work, which is a real cost worth naming. It is still the right call: the phone is where most people meet the page, and a hero that only works on a laptop is a hero that mostly does not work.

Encoding for scrubbing, not for streaming

A clip meant to be scrubbed wants the opposite settings from a clip meant to be watched. Streaming encoders space keyframes widely because sequential playback never needs them; scrubbing lands on arbitrary frames, and every seek that is not near a keyframe means decoding forward from the last one. Tighten the keyframe interval and the file gets larger but every seek gets cheaper — and on a scrubbed hero, seek cost is the only performance number that matters.

Figure outstanding

Final encode settings and resulting file size, landscape and portrait

Keyframe interval, CRF, resolution and delivered bytes for both clips. The specific numbers are what make this section citable rather than general advice.

Figure outstanding

Measured LCP on the hero, mobile 4G, with and without the clip

The honest cost of the technique. If it is expensive, that is worth publishing too.

The part that is not technique

Someone with vestibular sensitivity, or simply someone who has switched motion off, should get a still frame and no scrubbing at all. That check runs before any of the loading logic, so the fetch never happens and the machinery is never wired up — reduced motion should cost less, not the same.

The clip is also decorative. It carries no information the page does not state in text, it is marked as such for assistive technology, and if it fails to load the section still reads correctly with a poster behind it. A hero that becomes a blank rectangle when a fetch fails is a hero that has been given a job it should not have.

What we would do differently

Build the placeholder first, and make it good. We shipped a canvas schematic that assembles on scroll — the same gesture as the real clip, drawn in code — so the page read correctly for months while footage was still being rendered. That turned out to be more valuable than expected: it let the layout, the scroll distance and the copy be reviewed against real motion before anyone committed to an expensive render. Treat the placeholder as a design tool rather than a stopgap and the final clip arrives into a page that already fits it.

And decide the scroll distance before the edit, not after. The clip duration and the pinned scroll height are the same decision expressed twice, and discovering that a forty-five second sequence needs four viewport-heights of scroll to feel unhurried is much cheaper to find on a schematic than on a finished render.

Every trap here fails silently and only on hardware you do not have on your desk. That is the actual difficulty — not the technique, the feedback loop.

Thinking about a scroll-driven hero?

The honest first question is whether the sequence earns the weight it adds. We will tell you if a still would do the same job, which is more often than anyone selling this technique admits.

Four ways in

However you prefer.

Marine and yacht enquiries go straight to a named senior contact, and nothing is published without permission.