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