Loading film
0%
Scroll-driven video

You're not watching this. You're scrubbing it.

Every pixel you scroll moves the film forward one frame. Scroll back up — it rewinds. The video never plays on its own.

01 — The idea

Scroll position is a number between 0 and 1.

So is video time. Map one onto the other and the page stops behaving like a page. It becomes a timeline you drag with your thumb.

02 — The catch

Most attempts at this stutter badly.

A normal MP4 stores a full keyframe only every two seconds or so. Seek anywhere in between and the decoder has to rebuild that frame from the last one it knows. That rebuild is the jank.

03 — The fix

Three things make it glass-smooth.

All-keyframe encode

Re-encoded so all 720 frames are keyframes. Any seek is instant. Because smoke is mostly soft gradients, it still ships in 8 MB.

Preload to a blob

The entire file is fetched before the first frame moves, then handed to the player as an object URL. Nothing buffers mid-scrub.

Ease, don't snap

Scroll sets a target time. An animation loop eases toward it every frame. That slight weight is what reads as expensive.

04 — Proof

It's two numbers, tied together.

Watch the readout. Scroll is how far down the page you are. Time is the playhead. Nothing else is going on.

The end of the reel

That's the whole trick.

One video, one scroll listener, one easing loop. Keep scrolling for the recipe.

Scroll
Scroll0.000
Time0.00s
Frame0 / 720
akoum.me
Take it

Build this yourself, in one prompt.

Everything above is in the prompt below — including the encode step that people skip, which is the actual reason most attempts at this stutter. Paste it into any coding agent, or install it as a reusable skill.

Build a scroll-scrubbed video page: the page's scroll position drives a video's
playhead frame by frame. The video must never autoplay — you only ever set
currentTime.

STEP 1 — RE-ENCODE THE VIDEO. Do this first. It is the step everyone skips and
it is why most attempts stutter. A normal MP4 stores a keyframe only every ~2
seconds, so seeking between them forces the decoder to rebuild the frame from
the previous one. That rebuild is the jank. Make every frame a keyframe:

  ffmpeg -i input.mp4 -an -vf "fps=24,scale=1280:720:flags=lanczos" \
    -frames:v 720 -c:v libx264 -preset slow -crf 25 -pix_fmt yuv420p \
    -x264-params "keyint=1:min-keyint=1:scenecut=0" \
    -movflags +faststart story-1280.mp4

  Verify every frame is an I-frame (no P or B):
  ffprobe -v error -select_streams v:0 -show_entries frame=pict_type \
    -of csv=p=0 story-1280.mp4 | sort | uniq -c

  All-intra costs ~2-3x the bytes of a normal encode, but soft gradient footage
  (smoke, ink, fog, water) compresses so well it barely matters: 30s of 720p
  lands near 8 MB. Export a 854x480 variant for phones too.

  Footage rules: one continuous shot, no cuts, slow directional motion, and
  ideally a real arc — something that grows, floods or opens.

STEP 2 — STRUCTURE. A tall scroller (~900vh) containing a position:sticky
viewport that is 100svh tall. A <video muted playsinline preload="auto"> fills
it with object-fit:cover. Text "beats" are absolutely positioned inside.

STEP 3 — PRELOAD TO A BLOB. Don't stream it. fetch() the file, read the stream
to drive a real % loader, build a Blob, then set video.src to an object URL.
Enable scrubbing only after that resolves. Nothing buffers mid-scrub. Fall back
to the plain URL if fetch fails.

STEP 4 — THE LOOP.
  span     = scroller.offsetHeight - innerHeight
  progress = clamp(-scroller.getBoundingClientRect().top / span, 0, 1)
  target   = progress * video.duration
  each rAF: current += (target - current) * 0.12
            if (|current - lastSeek| > 0.005) video.currentTime = current
  Never assign currentTime straight from the scroll event. The lag the easing
  adds is exactly what reads as expensive instead of twitchy.

STEP 5 — TEXT BEATS. Give each an in/out range in progress space. Fade using an
ABSOLUTE fade width (~0.04), never a fraction of the range — proportional fades
make long beats cross-fade lazily and short ones snap. Start the first beat's
range below 0 so the hero is at full opacity on landing, and end the last one's
above 1 so the outro still holds at the bottom. Leave ~0.03 gaps between beats
so the footage plays alone for a moment.

GOTCHAS THAT WILL BITE YOU
- iOS locks a video until a gesture touches it. On first touchstart/pointerdown,
  call play() then immediately pause(). Wrap in .catch().
- Cache the scroll span. Mobile URL bars collapse mid-scroll, changing
  innerHeight; recomputing every frame remaps the timeline under the reader.
  Re-measure only on width change or a height change > ~140px.
- Add a gradient scrim and a soft text-shadow behind copy — bright footage eats
  white text. Cap heading measure in px, not ch: ch resolves against the body
  font size and strangles a 90px headline into a narrow column.
- prefers-reduced-motion: drop the easing and any parallax transforms.

VERIFY BEFORE SHIPPING
- ffprobe shows only I-frames.
- Median seek latency under ~5ms (set currentTime to a dozen random points and
  time the 'seeked' event). Higher means the encode is wrong.
- At progress 0 and 1, the first and last beats are at full opacity, not
  mid-fade.

Deliver one self-contained HTML file plus a media folder.

Skill format: unzip into your agent's skills folder (e.g. ~/.claude/skills/) and it loads on demand.

Who made this

Muhamad J. Akoum

Senior product engineer. I build iOS, Flutter, and web software, and I write about the parts that are easy to get wrong. The encode step above is one of them.