← All articles

[ Blog ]

July 9, 2026

9 min read

Scroll-Scrubbed Video Stutters? Fix the Keyframes

Your scroll scrubbed video stutters and only updates once a second? Here's the first-hand fix — an all-intra ffmpeg encode for frame-accurate GSAP scrubbing.

GSAPScrollTriggerVideoffmpegWeb PerformanceNext.js
Scroll-Scrubbed Video Stutters? Fix the Keyframes

I wired up an Apple-style scroll-driven hero video last month: a short clip that scrubs forward as you scroll, driven by a GSAP ScrollTrigger writing into video.currentTime. The code was textbook. The result was garbage. The frame only advanced every second or so, so instead of a buttery frame-by-frame reveal you got a slideshow that lurched in one-second chunks. If your scroll scrubbed video stutters and only updates once a second, you have exactly the bug I had — and it is not your JavaScript. It is your video file.

This is the write-up I wish I'd found that afternoon: why it happens, the one-line ffmpeg flag that fixes it, the file-size tax that comes with it, and the React driver that ties it together.

Why your scroll video only updates once a second

Here's the thing that took me an embarrassingly long time to internalize: video.currentTime = x does not give you frame x. It gives you the nearest seekable frame. And on a normal web video, seekable frames are rare.

Every H.264 or VP9 file is a mix of frame types. Keyframes (I-frames) are complete, self-contained images. Everything between them — the P- and B-frames — only stores the difference from other frames. That's the whole reason video compresses so well: most frames are tiny deltas, not full pictures. To display an arbitrary moment, the decoder needs the last full keyframe plus every delta since.

When you seek — when GSAP sets currentTime sixty times a second — the browser can only cheaply land on a keyframe. And a normal encode places a keyframe roughly once every 2 to 10 seconds (250 frames is a common default). So as you scroll smoothly from 0 to 1, currentTime sweeps continuously, but the picture snaps to the handful of keyframes it can reach. On a 10-second clip with keyframes every second, you get ten distinct frames across the whole scroll. That is your "once a second" stutter, exactly.

The fix is not to seek less, throttle, or debounce. The fix is to make every frame a keyframe so any currentTime you ask for is instantly, accurately seekable. That's an all-intra encode.

The ffmpeg fix: all-intra with -g 1

-g is the GOP size — the interval between keyframes. Set it to 1 and every single frame becomes an I-frame. Now every seek is exact.

Here are the two commands I ship. WebM (VP9) first because it's smaller and Firefox seeks it beautifully; MP4 (H.264) as the universal fallback for Safari.

# MP4 / H.264 — the fallback everything can play
ffmpeg -i in.mov -vf scale=1280:-2 -r 30 -movflags faststart \
  -vcodec libx264 -crf 23 -g 1 -pix_fmt yuv420p -an out.mp4

# WebM / VP9 — smaller, ship this first
ffmpeg -i in.mov -vf scale=1280:-2 -r 30 -c:v libvpx-vp9 \
  -crf 32 -b:v 0 -g 1 -pix_fmt yuv420p -an -row-mt 1 out.webm

What each flag is doing, and why it matters for scrubbing:

  • -g 1 — the whole point. Every frame a keyframe, so every currentTime seek is frame-accurate.
  • -vf scale=1280:-2 — scale to the render box (-2 keeps the aspect ratio and forces an even height, which yuv420p requires). This is not optional; see the file-size section below.
  • -r 30 — pin the frame rate. You want a known duration × fps so your scroll maps predictably to frames. 30 is plenty for a background hero.
  • -movflags faststart (MP4) — moves the moov atom to the front so the browser can start seeking before the whole file downloads.
  • -an — strip audio. A scrubbed hero is muted anyway, and the audio track only fights your seeks.
  • -pix_fmt yuv420p — the chroma format browsers actually decode. Skip it and Safari may show a black box.
  • -crf — quality knob. 23 (x264) / 32 (VP9) are good starting points; nudge them to trade quality for size.

The tax nobody warns you about: file size

All-intra means all keyframes, and keyframes are full frames, so you are throwing away most of the compression that made web video viable. A 1080p all-intra clip can blow past 100 MB — completely unshippable for a hero that has to load fast.

This is why the scale flag is load-bearing, not cosmetic. Scale the source down to the actual box it renders in. In my case, dropping to ~1280px wide at 30fps brought a clip that would have been enormous at full res down to roughly 8 MB for the MP4 and 13 MB for the WebM — treat those as illustrative, your mileage varies with length and motion, but they're the right order of magnitude for a hero.

Rule of thumb: never encode all-intra at a resolution larger than the element's rendered size at its largest breakpoint. A hero that's max 1280px wide on screen has no business shipping 1920px frames.

Normal encodeAll-intra (-g 1)
Keyframe interval~1 every 2–10s (250 frames typical)Every frame
Seek accuracySnaps to nearest keyframe (coarse)Frame-accurate
File size (~1280w, illustrative)~1–2 MB~8 MB MP4 / ~13 MB WebM
Best forLinear playback, streamingcurrentTime scrubbing

The trade is simple: normal encodes win at playback, all-intra wins at seeking. You cannot scrub smoothly on a playback-optimized file, and you shouldn't stream a scrub-optimized one. Pick per use case.

The React / Next.js ScrollTrigger driver

With a properly encoded file, the JavaScript is the easy part — but there are three non-obvious guards. Here's the component I use, with useGSAP for automatic cleanup.

'use client';

import { useRef } from 'react';
import { useGSAP } from '@gsap/react';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';

gsap.registerPlugin(ScrollTrigger, useGSAP);

export function ScrollScrubVideo() {
  const containerRef = useRef<HTMLDivElement>(null);
  const videoRef = useRef<HTMLVideoElement>(null);

  useGSAP(
    () => {
      const video = videoRef.current;
      if (!video) return;

      // Never assume metadata is ready — duration is NaN until it loads.
      const setup = () => {
        if (!video.duration || Number.isNaN(video.duration)) return;

        ScrollTrigger.create({
          trigger: containerRef.current,
          start: 'top top',
          end: 'bottom bottom',
          scrub: true,
          onUpdate: (self) => {
            // Map scroll progress (0–1) straight onto the timeline.
            const t = self.progress * video.duration;
            if (Number.isFinite(t)) video.currentTime = t;
          },
        });
      };

      if (video.readyState >= 1) setup();
      else video.addEventListener('loadedmetadata', setup, { once: true });
    },
    { scope: containerRef }
  );

  return (
    <div ref={containerRef} style={{ height: '300vh' }}>
      <div style={{ position: 'sticky', top: 0, height: '100vh' }}>
        <video
          ref={videoRef}
          muted
          playsInline
          preload="auto"
          style={{ width: '100%', height: '100%', objectFit: 'cover' }}
        >
          {/* WebM first for Firefox, MP4 fallback for Safari */}
          <source src="/hero.webm" type="video/webm" />
          <source src="/hero.mp4" type="video/mp4" />
        </video>
      </div>
    </div>
  );
}

The parts that bite people:

  1. Guard NaN duration. On mount, video.duration is NaN until metadata loads. Multiply that into your progress math and you write NaN to currentTime, which silently does nothing. Wait for loadedmetadata (or check readyState), and re-check Number.isFinite before every write.
  2. muted playsInline preload="auto", and never call .play(). The video stays paused — you are seeking it, not playing it. muted + playsInline keep mobile browsers from hijacking it into fullscreen or blocking it. preload="auto" matters (see the FAQ) because you cannot seek frames the browser hasn't buffered.
  3. scrub: true and cleanup. scrub ties currentTime to scroll position instead of firing once. useGSAP reverts the ScrollTrigger on unmount for you — do this manually and you'll leak triggers on every route change.

For the mechanics of scrub, pinning, and start/end, my GSAP ScrollTrigger tutorial is the deeper walkthrough, and the GSAP ScrollTrigger docs are the primary source. And MDN on HTMLMediaElement.currentTime is worth a read for the seek semantics that make all of this behave.

Loading order and the file it fetches

Ship WebM first in the <source> list. The browser picks the first source it can play, so Firefox and Chrome grab the smaller VP9 file and Safari falls through to the MP4. Get the order backwards and everyone downloads the bigger file.

One more thing worth flagging: I ran the encodes on a source file sitting in ~/Downloads, and ffmpeg hit a macOS permissions wall — the OS blocked it from reading the folder. If your encode dies with a cryptic "Operation not permitted," that's macOS TCC protecting the folder, not a bug in your command. Move the file out of the protected folder (~/Downloads, ~/Desktop, ~/Documents) or grant your terminal Full Disk Access, and it clears up.

FAQ

Why does my scroll video only update once a second?

Because your video's keyframes are about a second apart. video.currentTime can only cheaply seek to keyframes, so a normal encode (one keyframe every few seconds) lets you land on only a handful of frames across the whole scroll. Re-encode all-intra with -g 1 so every frame is seekable.

WebM or MP4 for scroll scrubbing?

Both — ship WebM (VP9) first for smaller files and great seeking in Firefox and Chrome, with MP4 (H.264) as the fallback for Safari, which doesn't reliably play VP9. List the <source> tags WebM-first so capable browsers fetch the smaller file.

How big will an all-intra video be?

Much bigger than a normal encode, because every frame is a full keyframe. A 1080p all-intra clip can exceed 100 MB. The cure is to scale down to the rendered box: at ~1280px wide / 30fps I've landed around 8 MB (MP4) and 13 MB (WebM). Never encode larger than the element's on-screen size.

Do I need to preload the whole video?

For frame-accurate scrubbing, effectively yes — use preload="auto". You can't seek to a frame the browser hasn't buffered, so partial preload gives you gaps and stalls mid-scroll. This is another reason to keep the file small: preload="auto" on a 100 MB file is a terrible first-paint. Small all-intra file + preload="auto" is the combination that feels instant.

Can't I just throttle the seeks instead?

No. Throttling changes how often you seek, not where you can land. The coarseness comes from the keyframe interval in the file, so no amount of JS smoothing fixes a playback-encoded video. Fix the encode first; then the driver above is smooth without any throttling.


Same lesson, one line: currentTime scrubbing demands an all-intra encode (-g 1), and because all-intra is all keyframes, you must scale to the render box or the file balloons. Do both and the jank disappears.

If you want to see this technique in a shipped hero, the projects page has a few, and I build these end-to-end as part of performance-focused frontend work.