All articles

Performance

Scroll-scrubbed video on a cheap phone

Driving a video's playhead from scroll position is easy to demo and hard to ship. The easing, the seek threshold, two encodes instead of one crop, and the viewport units that stop the composition resizing under your visitor's thumb.

Jithin Roy, Full Stack Developer

By

Full Stack Developer, Kerala, India

7 min read

Scroll-scrubbed video is one of those effects that looks like a weekend project and turns out to be a week. The demo is four lines: read the scroll progress, multiply by the duration, assign to currentTime. It works beautifully on the machine you built it on and falls apart on a three year old Android.

The reason is that you are not playing a video, you are seeking it, dozens of times a second, against a decoder that was designed for sequential playback. Everything below is about staying inside what that decoder can actually do.

Never assign the scroll position directly

The naive version writes the scroll-derived time straight to the video. Scroll events arrive in coarse jumps, especially from a mouse wheel or a flung touch, so the playhead teleports. The result reads as a filmstrip being flicked rather than motion.

Split it into two values. Scroll writes a target. A requestAnimationFrame loop eases the current position toward that target and writes the result:

const ease = 0.14

const tick = () => {
  const active = activeVideo()
  if (active && Number.isFinite(active.duration) && active.duration > 0) {
    const target = targetRef.current
    let current = currentRef.current
    current += (target - current) * ease      // exponential approach
    currentRef.current = current

    if (!active.seeking && Math.abs(active.currentTime - current) > 0.025) {
      try { active.currentTime = current } catch { /* ignore */ }
    }
  }
  rafRef.current = requestAnimationFrame(tick)
}

Three separate decisions are packed into that block and each one matters.

The easing factor of 0.14 means the playhead closes about fourteen percent of the remaining gap per frame. Higher feels twitchy and starts to reintroduce the teleporting. Lower feels like the video is lagging behind the user's thumb, which is worse, because it reads as the page being slow rather than as a deliberate effect.

The 25 millisecond threshold skips seeks that are not worth making. Without it you issue a seek every single frame, including hundreds of sub-frame ones as the easing converges, and the decoder never gets to settle.

And the seeking check is the one that saves you on weak hardware. Assigning currentTime while a seek is already in flight queues work the decoder has no chance of catching up on. The queue grows, the video stutters, and it looks like the video is too big when the real problem is that you are asking for more seeks than the device can serve.

The general shape: sample input into a target, converge toward it in an animation frame, and refuse to issue work while the previous piece is still outstanding. That pattern applies well beyond video.

Warm the decoder before anyone needs it

A video that has loaded its metadata has not necessarily decoded a frame. The first seek is therefore the expensive one, and it lands exactly when the visitor starts scrolling, which is the worst possible moment.

const warm = () => {
  try { video.currentTime = 0.001 } catch { /* ignore */ }
}
video.addEventListener("loadedmetadata", warm)
if (video.readyState < 1) video.load()
else warm()
A seek to one millisecond forces a decode. By the time scrolling starts, there is a frame ready.

Note the branch on readyState. If metadata already arrived before the effect ran, the event has fired and will not fire again, so warming has to happen immediately instead. Listener-only versions of this work in development and intermittently fail in production, because in production the file is sometimes already cached.

Two encodes, not one crop

The obvious way to handle phones is to ship one landscape video and let CSS crop it with object-fit. It is one file, it is less work, and it is wrong on both counts that matter.

A wide video on a tall screen wastes most of its bytes on pixels that are cropped away, and the composition is ruined: whatever was framed nicely in a sixteen by nine shot is now a tight crop of somebody's shoulder. So there are two genuinely separate files, and the scrub loop tracks whichever is actually on screen:

const activeVideo = () =>
  window.matchMedia("(min-width: 768px)").matches
    ? landscapeRef.current
    : portraitRef.current

Resolving the active video inside the loop rather than capturing it once means a rotation or a resize is handled for free. There is no re-registration, no listener, no stale reference: the next frame simply asks again.

One more small thing with a real cause. The target is clamped just short of the end:

targetRef.current = t * Math.max(0, active.duration - 0.04)
Seeking to exactly duration can land past the last decodable frame and give you a blank.

Viewport units are where mobile actually breaks

This has nothing to do with video and it will ruin the effect anyway.

On mobile browsers the address bar hides as you scroll down and returns as you scroll up. Size your sticky stage in vh and the stage changes height mid scroll, so the composition jumps at the exact moment the visitor is looking at it. Worse, on some browsers that resize feeds back into scroll position and the whole thing oscillates.

<div class="relative h-[380svh]">
  <div class="sticky top-0 h-[100svh] [@supports(height:100dvh)]:h-[100dvh] overflow-hidden">
svh is the small viewport height, the size with browser chrome showing. dvh tracks the live value where supported.

Small viewport height is the conservative choice: it is the height with the chrome visible, so the layout is correct in the worst case and never has to shrink. The support query then upgrades to dynamic viewport height where the browser has it. Writing it in that order means old browsers get something that works rather than something that breaks.

The outer container being several times the viewport height is what creates the scroll distance the effect runs over. That number is a pacing decision, not a technical one, and it is worth tuning on a real phone rather than a resized desktop window.

The scroll range gotcha that fades the wrong thing in

This one cost an afternoon and the fix is one string.

// Map progress to the sticky-active range (container top to bottom aligned
// with the viewport). Using "end start" would keep driving progress after
// sticky unsticks, so the hero fades in while the next section already peeks
// underneath.
const { scrollYProgress } = useScroll({
  target: containerRef,
  offset: ["start start", "end end"],
})

A sticky element stops being stuck when its container's bottom reaches the bottom of the viewport, not when the container leaves the screen entirely. If your progress range keeps advancing past that point, the last stretch of your animation plays while the stage is already scrolling away and the following section is visible underneath it. Match the progress range to the range over which the element is actually stuck.

Degrade in two directions

Reduced motion is not a smaller version of this effect, it is a different layout. No sticky stage, no scrub, no video: plain stacked sections in document order. Treating it as a real design rather than a disabled animation is the difference between something usable and something that technically respects the setting.

The second direction is the network. Media loads from a CDN with a local fallback:

function fallbackToLocal(video: HTMLVideoElement, localSrc: string) {
  const local = localMediaPath(localSrc)
  if (video.dataset.fallback === "1") return   // only ever try once
  video.dataset.fallback = "1"
  video.src = local
  video.load()
}

The guard is the important line. Without it, an error handler that reassigns src can trigger another error, which reassigns src again, and a CDN outage becomes an infinite reload loop hammering your own origin at the moment it is least able to cope. Any fallback that reacts to an error needs to be able to say it has already tried.

The opening rearranges itself

Worth mentioning because it is the reason the component is shaped the way it is. The site promotes live events when there are any, and does not when there are none. So the opening has two arrangements: with events, the promo leads, the video scrubs, and the brand hero reveals at the end. Without them, the hero leads, the video scrubs, and the sticky releases into the content.

One flag chooses, and the container height changes with it because a three act sequence needs more scroll distance than a two act one. Everything else is the same code path. The alternative, two components sharing a scrub implementation, is the duplication that always drifts.

What to take from this

  1. Ease toward a target instead of assigning scroll position directly. Around 0.14 per frame felt right.
  2. Skip seeks below a threshold, and never seek while the element is already seeking.
  3. Warm the video on metadata load, and handle the case where metadata already arrived.
  4. Ship separate portrait and landscape encodes. Cropping a wide video wastes bytes and ruins the framing.
  5. Resolve the active element inside the loop, so rotation and resize need no extra handling.
  6. Clamp the target just short of the duration.
  7. Size sticky stages in svh with a dvh upgrade, or browser chrome resizes your composition mid scroll.
  8. Match the scroll progress range to the range the element is actually stuck for.
  9. Make reduced motion a real layout, not a disabled animation.
  10. Guard any error-driven fallback so it cannot loop.

None of this is exotic, and that is the point. The effect is not hard because the maths is hard; it is hard because a video decoder, a mobile browser's chrome, and a sticky container all have behaviour you have to work with rather than around. The same principle drove the 3D hero on the same platform, which is written up separately, and the wider build is in the Synerggy Events case study.