Technical note

Scroll-scrubbed video backgrounds that don't silently break

The effect: a full-viewport video sits fixed behind the page, and scrolling drives the playhead. Top of page is the first frame, bottom is the last.

The browser never plays the video. Every frame you see is a paused seek. The whole idea fits in 2 lines:

progress = scrollY / (scrollHeight - innerHeight); video.currentTime = progress * duration

That math is correct. We shipped it on a demo site and it also stuttered, froze, and sat on frame 0 at various points along the way, because the 2 things that decide whether scrubbing works are not in the JavaScript at all.

The encode decides 90% of it

A normal video file stores most frames as differences against earlier frames, with a full keyframe only every few seconds. Think of a flipbook where most pages just say "same as the last drawing, ball moves an inch." To open that book at a random page, you flip back to the last full drawing and replay forward. A scrub seeks up to 60 times a second, and that walk-back is the stutter.

The fix is an all-intra encode: every frame is a keyframe, so any frame decodes directly.

ffmpeg -i source.mp4 -vf "scale=1280:720,fps=60" -c:v libx264 -pix_fmt yuv420p -g 1 -keyint_min 1 -crf 23 -an -movflags +faststart out.mp4

-g 1 -keyint_min 1 is the pair doing the work. Verify with ffprobe that every frame reports type I. We went 720p because all-intra costs bitrate, and 720p under object-fit: cover with text on top is indistinguishable from 1080p at half the weight.

Our shipped file is 11.8 MB for 8 seconds at 60 fps. Measured on the result: 30 random seeks averaged 3.8 ms against a 16.7 ms frame budget. We also tried x265 at 4 different frame rates first. Every one of them scrubbed worse than all-intra x264.

The server can veto everything

Browsers seek video with HTTP Range requests. If the server does not answer 206 Partial Content with Accept-Ranges: bytes, Chrome marks the file unseekable and silently clamps every seek back to 0. No error. No warning. The frame just never moves.

nginx, Apache, S3, and CDNs handle Range out of the box. python3 -m http.server does not, and that one cost us real time: the identical page worked in production and froze at frame 0 on the local test server. Confirm with curl -sI url.mp4 | grep -i accept-ranges before you blame your code.

The rest of the trap list

Seeks are async, so never issue one while the last is in flight. Gate on the seeked event, and add a 250 ms timeout that clears the gate if the event never arrives, because one lost event freezes the effect permanently.

Smooth the motion with time-based decay, not a per-frame constant, or the animation runs twice as fast on 120 Hz displays. And back the final seek off the exact video duration by a millisecond. Safari misbehaves at the end otherwise.

Two branches before any of that runs: users with reduced motion set at the OS level get a static poster and zero video bytes. Touch devices get a small looping file instead of a scrub, because paused-video seeking on iOS Safari is broken in more ways than it is worth fighting. Detect touch with navigator.maxTouchPoints, not viewport width. iPads and landscape phones slip through width breakpoints.

Get the encode and the server right and the effect is smooth and honestly kind of rad. Get either wrong and it fails with no error message at all. That is the whole reason this note exists.