Guides · 6 min read · Updated
How do you build a scroll-scrubbed video hero?
Encode the video so every frame is cheap to seek, with a short keyframe interval and the index at the front of the file, then set video.currentTime from scroll progress inside a requestAnimationFrame loop with smoothing. Serve the file from a host that supports byte ranges, and ship a poster image plus a lighter fallback for phones and reduced motion.
Scrubbing is a seeking problem
A scroll-scrubbed hero is a video that never plays. Scroll position becomes a time, you set currentTime to that time, and the browser shows the frame there. Scrolling down plays forward, scrolling up plays backward, and stopping freezes the picture.
That makes every scroll event a seek, and seeks are what normal video files are bad at. Compressed video stores a full picture only at keyframes and stores the frames between them as small changes. To show one frame the decoder jumps to the nearest earlier keyframe and decodes forward to your frame. A file with a keyframe every ten seconds can mean decoding hundreds of frames for one tap of the wheel. So the work is mostly in the encode, not the JavaScript.
Encode for seeking
Three settings matter most: a short keyframe interval, the index at the front of the file, and a size that a phone can decode quickly. This command makes an H.264 file that scrubs well. It drops the audio track, scales to 1920 pixels wide, sets 30 frames per second, and forces a keyframe every 15 frames.
ffmpeg -i hero-source.mov -an \
-vf "scale=1920:-2,fps=30" \
-c:v libx264 -preset slow -crf 23 -pix_fmt yuv420p \
-g 15 -keyint_min 15 -sc_threshold 0 \
-movflags +faststart \
hero.mp4-g 15 -keyint_min 15 -sc_threshold 0: a keyframe every 15 frames and no extra scene-cut keyframes, so the worst case is decoding 14 frames per seek. A smaller number scrubs more smoothly and makes the file bigger. Try 10 to 30 and compare.-movflags +faststart: moves the file's index (the moov atom) to the front, so playback and seeking can start without downloading the whole file first. FFmpeg's docs describe it as an extra pass that can take a while, and it does not work with fragmented output.-pix_fmt yuv420p: the pixel format that browsers and phones decode most widely.-an: removes audio. A muted hero does not need the bytes.-crf 23: the quality dial. Raise it toward 28 for a smaller file on mobile.
Also render a poster frame and a WebM if you want a second source. The poster is what shows while the video loads and what reduced-motion visitors keep.
ffmpeg -i hero.mp4 -frames:v 1 -q:v 3 poster.jpg
ffmpeg -i hero-source.mov -an -vf "scale=1920:-2,fps=30" -c:v libvpx-vp9 -crf 34 -b:v 0 -g 15 hero.webmKeep the clip short. Scrub heroes work best at 3 to 8 seconds of footage, because the viewer controls the pace and a long clip means a long scroll or a rushed one. Plan the shot so the last frame is a good place to stop.
Byte ranges: why your host matters
Browsers seek in video by asking for part of the file. A request with a Range header, such as Range: bytes=0-1023, asks for those bytes only, and a server that supports it answers with status 206 Partial Content and a Content-Range header. A response that includes Accept-Ranges with a value other than none tells the browser ranges work. A server that ignores the header sends the whole file with status 200, and seeking gets slow or stalls.
Check it from a terminal before you blame your code. You want a 206 and an Accept-Ranges line:
curl -sI -H "Range: bytes=0-1023" https://your-site.example/hero.mp4 | head -n 12Most static hosts and CDNs do this by default. Trouble tends to appear behind a custom proxy, a serverless function that streams the file, or a compression layer that rewrites the response. Serve the video as a static file with a long cache time, and put it on the same CDN as your other assets.
The page: sticky stage and a tall track
The standard layout is a tall section that sets how much scrolling the effect gets, with a sticky stage inside it that holds the video. Height of 400vh gives you roughly three screens of scrubbing. The video is muted, inline on iOS, and never autoplays, because you control the time.
<section class="track" id="track">
<div class="stage">
<video id="hero" muted playsinline preload="auto"
poster="/poster.jpg" aria-hidden="true">
<source src="/hero.mp4" type="video/mp4" />
</video>
<h1 class="title">Your headline</h1>
</div>
</section>
<style>
.track { height: 400vh; position: relative; }
.stage { position: sticky; top: 0; height: 100vh; overflow: hidden; }
.stage video { width: 100%; height: 100%; object-fit: cover; }
</style>The scrub loop, with smoothing
Do not set currentTime inside the scroll event. Read scroll into a target in the event, and move toward it inside requestAnimationFrame. That gives you one seek per frame at most and lets you smooth the motion so it glides instead of stepping with each wheel notch. The callback also receives a timestamp, so you can ease by elapsed time and keep the feel the same on a 120 Hz screen.
const video = document.getElementById("hero");
const track = document.getElementById("track");
let duration = 0;
let target = 0; // 0 to 1, from scroll
let current = 0; // smoothed
let last = performance.now();
let started = false;
// load() fires this again, so start the loop only once.
video.addEventListener("loadedmetadata", () => {
duration = video.duration;
video.pause();
if (!started) {
started = true;
requestAnimationFrame(tick);
}
});
function progress() {
const rect = track.getBoundingClientRect();
const span = rect.height - innerHeight;
return Math.min(1, Math.max(0, -rect.top / span));
}
addEventListener("scroll", () => { target = progress(); }, { passive: true });
function tick(now) {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
current += (target - current) * (1 - Math.exp(-dt * 8));
const t = current * duration;
// Skip tiny moves: a seek costs more than the pixels it changes.
if (Math.abs(video.currentTime - t) > 1 / 60) video.currentTime = t;
requestAnimationFrame(tick);
}The threshold of one sixtieth of a second avoids seeking for changes smaller than a frame. Raise the easing number for a snappier response and lower it for more glide. If you see the video flicker back and forth, you are probably setting currentTime faster than the decoder can finish; widen the threshold or shorten the keyframe interval.
There is also a fastSeek() method that trades precision for speed, but MDN notes it is not available in all widely used browsers, so currentTime is the dependable choice.
Poster and phone fallbacks
A scrubbed hero is the heaviest thing on most pages that use one, so decide who gets it. Visitors who asked for reduced motion, who turned on data saving, or who have a weak connection should get the poster as a plain image, with the headline over it. Phones can still scrub if you give them a smaller file, but be honest about the cost: decoding at seek time is real work for a phone chip.
const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;
const saveData = navigator.connection && navigator.connection.saveData;
const phone = matchMedia("(max-width: 700px)").matches;
if (reduce || saveData) {
document.documentElement.classList.add("no-scrub"); // CSS shows poster.jpg only
} else {
const source = document.querySelector("#hero source");
if (phone) source.src = "/hero-720.mp4"; // a 720p, crf 28 encode of the same clip
document.getElementById("hero").load();
}Make the 720p version the same way as the main one with scale=1280:-2 and -crf 28. In CSS, .no-scrub video { display: none; } and give .stage the poster as a background image. The track can also shrink to a single screen so these visitors do not scroll through an empty stretch.
Shoot and cut the clip for scrubbing
Footage that looks good played at normal speed can look wrong when a viewer drags it back and forth. Scrubbing rewards a single, continuous camera move: a push in, an orbit, a slow reveal. Hard cuts feel jarring when the viewer controls the pace, and fast handheld motion turns into a smear when frames are skipped.
Plan the first and last frames as stopping points. Most visitors will park somewhere along the way, so every intermediate frame should be a picture you would be happy to freeze on. If the headline fades in over the clip, tie it to the same progress value, so the words and the picture stay in step.
- Keep the subject centered or on a clear line, since
object-fit: covercrops the edges on tall phone screens. - Avoid fine repeating patterns such as thin stripes, which shimmer when compressed at a low bitrate.
- Export at the display frame rate you need, not higher. Thirty frames per second is usually enough because the viewer sets the pace.
- Test with a trackpad and a notched mouse wheel. A trackpad gives many tiny scroll steps, a wheel gives few large ones, and your smoothing has to handle both.
Debugging a scrub that feels wrong
Most scrub problems fall into three groups. If the picture lags behind the scrollbar, the decoder is the bottleneck, so shorten the keyframe interval or lower the resolution. If it jitters back and forth, your loop is fighting itself, usually because currentTime is set from two places or the smoothing factor is too high. If it works on your laptop and fails on a phone, the file is too large for the device, so serve the smaller encode.
Use the browser's network panel to confirm the video arrives with status 206 and that only the needed ranges are requested while you scrub. Then record a Performance trace while scrolling and look for long tasks on the main thread. A well-encoded file shows short, regular seeks and no long tasks.
When to use a frame sequence instead
A second approach skips video decoding. You export the clip as a numbered image sequence and draw the right frame onto a canvas. Seeking becomes instant and exact, which is why you see it in the catalog as canvas frame scrub, but the total download is far larger than a compressed video and the frames all need to load. Video wins on bytes, a frame bank wins on precision. For a short, hero-sized clip on a fast connection either works; for anything longer, use video.
Whichever you choose, treat the scroll length as part of the design. Test the effect on a trackpad, a mouse wheel with notches, and a phone swipe, because each produces scroll events differently.





