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.

encode.sh
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.

poster.sh
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.webm

Keep 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:

check-ranges.sh
curl -sI -H "Range: bytes=0-1023" https://your-site.example/hero.mp4 | head -n 12

Most 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.

hero.html
<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.

scrub.js
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.

fallback.js
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: cover crops 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.

Templates that do this

Live demos built on video: scroll films, video heroes and scrubbed footage. Picked from the catalog when the page is built.

Questions people ask

Why does my scroll-scrubbed video stutter?+

Almost always the file has too few keyframes. Re-encode with a keyframe every 10 to 15 frames (for example -g 15 -keyint_min 15 in ffmpeg), put the index first with +faststart, and set currentTime from a requestAnimationFrame loop instead of the scroll event.

What does faststart do for a scrubbed video?+

It moves the MP4 index (the moov atom) to the start of the file, so the browser can begin playback and seeking without downloading the whole file first. In ffmpeg you enable it with -movflags +faststart. FFmpeg's documentation notes it takes an extra pass and does not work with fragmented output.

Does the server need to support byte ranges?+

Yes, for dependable seeking. The browser asks for slices of the file with a Range header and a supporting server replies with 206 Partial Content. Check with curl -sI -H "Range: bytes=0-1023" and look for 206 and an Accept-Ranges line.

Can a scrubbed video hero work on phones?+

It can if you serve a smaller encode, such as 720p at a higher CRF, and keep the clip short. Give visitors with reduced motion or data saving turned on a poster image instead of the video.

Should I scrub with video or a canvas image sequence?+

Use video when download size matters, which is most of the time. Use a canvas image sequence when you need exact frames and instant seeking and can afford a much larger download.

Facts checked on

What this guide says about other products and web standards comes from the official pages linked below, and those can change, so check them for the latest. The code samples are our own. What it says about AnimatedWebs comes from how our own site works.

  • FFmpeg's mov and mp4 muxer has a faststart movflag that moves the index (the moov atom) to the front of the file through an extra pass. It is off by default and does not work with fragmented output. frag_keyframe starts a new fragment at each video keyframe. FFmpeg documentation: formats ↗
  • A client asks for part of a resource with the Range header. A server that supports it answers 206 Partial Content with a Content-Range header, an Accept-Ranges value other than none signals support, and a server that ignores the range returns 200 with the full body. MDN: HTTP range requests ↗
  • HTMLMediaElement.fastSeek() seeks quickly at the cost of precision, and setting currentTime seeks precisely. fastSeek is not Baseline because some widely used browsers do not support it. MDN: HTMLMediaElement.fastSeek() ↗
  • requestAnimationFrame calls your callback before the next repaint and passes a high-resolution timestamp. Calls are generally paused in background tabs. MDN: requestAnimationFrame() ↗

Product and company names are trademarks of their owners. AnimatedWebs is independent and not affiliated with them.

See the technique running

Play the live demos, open the one you like, and start with a free template or unlock the whole library.

We use essential cookies to keep you signed in, a referral cookie only if you arrive through a partner link, and cookie-free analytics. No ads, no tracking. Cookie Policy