tool / scroll videoscroll · seek · frame

Scroll video scrub generator

A video that plays forwards and backwards with the scroll: the product-page effect. Pin it or let it scroll, set the trigger window, the range and the easing, try it on a video from your own disk, and copy the HTML, CSS and JavaScript or a React component. Then encode the film so every seek is quick.

Options
11
Keyframe every
3 frames
Uploads
0
Library
2.23.7
scrollVideo(video, { trigger, from, to })scroll position · progressstart · 0end · 10.49time = from +(to − from) × 0.49from 0to 4.98012345s2.4 s · frame 73keyframes: 1 frame in 3666768697071727374757677786667686970717273747576777860-g 3 · this film: 2 frames decoded, from frame 72-g 60 · one every 2 s: 14 frames decoded, from frame 60keyframedecoded on the waythe frame asked for
Fig. 1 — The trigger window maps straight onto the film, from to to. Every new scroll position is a seek, and a seek decodes from the keyframe before the frame it lands on: with one every 3 frames, never more than 3 frames.
01The tool

Scrub a video with the scroll

The box below is a small window. Scroll it, drag the slider or press Play: the scroll is turned into a progress with scrollVideo’s own arithmetic and handed to a real scrollVideo instance, which writes the video’s currentTime. The readouts read the video back, and time every seek. The film is this site’s own, 1120×840 at 30 fps; pick a file of yours to see how it scrubs.

Video

film.webm · VP8 1120×840 · 5.4 s · 30 fps · a keyframe every 3 frames. Or pick a video from your disk: it stays in this browser.

Pin the video while it scrubsA sticky stage in a tall track; scrollVideo measures the track (triggerElement).
Scroll length · track 400vh400vhStuck for 300vh; the film runs over 300vh of scroll, exactly while stuck.
Trigger windowstartendExactly while the stage is stuck: from the track’s top at the window’s top to its bottom at the window’s bottom.
Range · from → to0 s4.98 sThis film stops at 4.98 s: colourway stills follow it in the same file.
EasingOne scroll pixel is the same slice of film everywhere.
PosterShown until the first frame is ready, and instead of the film where a browser cannot play it.

Window · 360px tall · scroll it · the stage scrolls

Content beforescroll ↓
Content after
Progress, eased0.00
currentTime– s
Film · 0 to 5.4 s · 54 keyframes

Waiting for the film.

Last seek
–
Slowest
–
Seeks
0

From the seeking event to seeked, in this browser. Tens of milliseconds means few keyframes: see section 04.

film.js13 lines
// Made with https://svg-scroll-draw.vercel.app/scroll-video-scrub-generator
import { scrollVideo } from 'svg-scroll-draw/video';

const film = scrollVideo(document.querySelector('#film'), {
  // The stage is sticky, so the video's own box moves with the scroll.
  // Measure the track that holds the scroll room instead.
  triggerElement: document.querySelector('#film-track'),
  trigger: { start: 'top top', end: 'bottom bottom' },
  to: 4.98, // stills follow the film in this file
});

// film.destroy() lets the video go. With reduced motion on, scrollVideo
// shows the frame at `to` and does not scrub.

The HTML, CSS and JavaScript tabs are one page. Options at scrollVideo’s defaults are left out. The downloaded file plays this site’s film.

Run it
Preview
scrollVideo follows the window’s scroll and has no scrollContainer option, so the preview works out the progress from the box’s scroll (the same arithmetic, fig. 1) and passes it to the instance’s seek(). On your page the instance follows the window by itself.
Recipe
For the whole pinned film with chapters of copy that follow it, see the scroll-video recipe; for the same scrub drawn in lines instead of footage, the blueprint phone on the examples page.
02The arithmetic

From a scroll position to a frame

scrollVideo measures one element, the video or its triggerElement, and turns the two trigger pairs into two scroll positions, as every scroll animation in the library does. Between them, progress runs from 0 to 1 in a straight line; the easing bends it; and the time is that share of the way from from to to (fig. 1).

what-it-does.js7 lines
// What scrollVideo does on each animation frame while the video is on
// screen (video/index.ts in svg-scroll-draw), simplified:
const progress = Math.min(1, Math.max(0, (scrollY - startAt) / (endAt - startAt)));
const time = from + (to - from) * easing(progress);

// Every write to currentTime starts a seek, so it writes only on a change.
if (time !== lastTime) video.currentTime = lastTime = time;
When
The loop runs on animation frames while the video is on screen, watched by an IntersectionObserver, and stops when it is not. The two positions are measured when it is made, 150ms after a resize, and when the measured element’s layout changes.
Pinned
In a sticky stage the video’s own box moves with the scroll for as long as it is stuck, so measured on the video the window comes out wrong, and a resize in mid-pin (a phone’s address bar) measures it wrong again. Measure the track instead, with start: 'top top' and end: 'bottom bottom': the stretch the stage is stuck.
Reduced motion
With reduced motion asked for, scrollVideo seeks once to to when the metadata is in and never follows the scroll; turned on during a visit, it seeks to that frame and stops there. There is no option to change this. The generator’s check box shows it.
seek()
seek(p) shows progress p and stops the instance following the scroll until resume(). A seek() made before the video’s metadata has loaded does nothing, and is not applied when it loads: wait for onReady.
0311 options

Every scrollVideo option

scrollVideo(target, options) takes a <video> or a selector for one and returns an instance. Leave an option out and it takes the default; the generator leaves out every option at its default.

OptionTypeDefaultWhat it does
trigger{ start?, end? }'top top' → 'bottom top'The scroll window, in the two-word form: an edge of the measured element, then a line across the window. Progress is 0 at start and 1 at end. The default scrubs from when the video’s top reaches the top of the window until its bottom does: while it leaves.
triggerElementElementthe videoMeasure the window on this element instead of the video. A video in a sticky stage moves with the scroll while it is stuck, so measure the tall track that holds the scroll room.
fromnumber0The time, in seconds, at progress 0.
tonumberthe durationThe time at progress 1. Left out, it is the video’s duration, read once, when the metadata loads.
easing'linear' | 'ease-in' | … | (t) => number'linear'Shapes progress before it becomes a time. The overshooting ones, spring and elastic, go past 1, and the time with it: past to (see the note below).
oncebooleanfalseKeep the furthest point reached: scrolling back up does not rewind. In 2.23.2 and earlier it kept the eased value at its highest, so spring or elastic stayed on their peak; fixed in 2.23.3, which keeps the scroll progress and eases that.
axis'x' | 'y''y'Which scroll to follow. 'x' reads the window’s horizontal scroll and the element’s left edge and width.
preload'auto' | 'metadata''auto'Set on the video only when it has no preload attribute of its own.
onReady() => void—Once the metadata is in and to is known.
onProgress(progress) => void—Each time a time is written, with the eased progress.
onComplete() => void—When progress reaches 1, and again after it has dropped below 1 (unless once).
Instance
seek(p), pause(), resume(), getProgress() (the eased progress) and destroy(), which takes the observers and listeners off. replay() goes back to the first frame; in 2.23.3 and earlier, called after pause() it did not follow the scroll again until the video left the screen and came back, fixed in 2.23.4. On the server, or for a target that is not a video, scrollVideo returns an instance that does nothing.
Overshoot
The time is not clamped to the range, so spring and elastic, which pass 1, ask for frames after to (the browser stops at the end of the file), and bounce runs the film back and forth through the second half of the window. The generator offers the four smooth easings.

What the <video> element needs:

Give itWhy
muted, playsinlinescrollVideo never calls play(): it only writes currentTime. These are for anything else that might start the video: without playsinline an iPhone plays video in its fullscreen player, and muted keeps a play() allowed by autoplay rules. The library’s React ScrollVideo sets both by default.
preload="auto"Asks the browser for the whole file, so a seek lands on data that is already there. scrollVideo sets it when the attribute is missing.
Range requestsBrowsers seek a video by asking the server for byte ranges. The scroll-video recipe measured Chromium against a server that ignores them: asked for 3 s, the film stayed at 0. Apple documents byte ranges as required for media on iOS. Check for 206 Partial Content and Accept-Ranges: bytes.
posterShown until the first frame is ready, and instead of the film where a browser cannot play it. Make it the frame the scrub starts on.
no autoplay, loop or controlsscrollVideo pauses the video once, when it is made, and never again; autoplay would start it on its own clock. Controls would let a click do the same.
React
The generated React code calls scrollVideo in an effect rather than rendering the library’s <ScrollVideo>: that component has no poster prop, always renders preload="auto" (so its preload option never applies), and a triggerElement needs a ref to the track anyway.
04Encoding

Encode the video for smooth scrubbing

A video file stores a few whole pictures, keyframes, and between them only what changed. To show any other frame, the decoder starts at the keyframe before it and works forward. Playing, that costs nothing extra. Scrubbing, every new scroll position is a seek, so each one pays for the frames since the last keyframe, and exports usually put keyframes seconds apart: left to their defaults, both encoders below put 2 in this 162-frame film. A short interval, set with -g, keeps every seek short, at the cost of a bigger file.

EncodeKeyframesSizeChromium msFirefox msWebKit ms
VP8 -g 354 / 1621628 KB8 · 114 · 56 · 12
VP9 -g 1162 / 1622922 KB6 · 73 · 52 · 4
VP9 -g 354 / 1621112 KB4 · 64 · 53 · 4
VP9 -g 1511 / 162491 KB6 · 137 · 107 · 17
VP9 default2 / 162383 KB26 · 7225 · 5834 · 116
H.264 -g 1162 / 1622073 KB5 · 73 · 41 · 2
H.264 -g 354 / 162954 KB6 · 183 · 71 · 2
H.264 -g 1512 / 162573 KB7 · 114 · 61 · 3
H.264 default2 / 162487 KB37 · 7918 · 352 · 24

Each cell is the median seek, then the 95th percentile: the slowest 5% took at least that. The site’s film (VP8, 1120×840, 5.4 s) was re-encoded with only -g changed, served with Range support, buffered whole, then sought to every frame, last to first and back, twice, in Playwright 1.65.0’s Chromium 158, Firefox 159 and WebKit 27.2 on an Apple-silicon Mac. Firefox and WebKit report whole milliseconds. One film on one machine: the generator times your own.

encode.sh12 lines
# VP9 in WebM: a keyframe every 3 frames, no audio track
ffmpeg -i input.mov -an -c:v libvpx-vp9 -b:v 0 -crf 32 -g 3 -row-mt 1 film.webm

# H.264 in MP4, the same keyframes; +faststart puts the index first
ffmpeg -i input.mov -an -c:v libx264 -preset slow -crf 20 -g 3 \
  -pix_fmt yuv420p -movflags +faststart film.mp4

# Count the keyframes in what you made
ffmpeg -i film.mp4 -vf showinfo -f null - 2>&1 | grep -c "iskey:1"

# The poster: the frame on screen at 0 s
ffmpeg -i film.mp4 -vf "select=lte(t\,0)" -update 1 -q:v 2 poster.jpg
General advice
These commands are a starting point, not a rule. What was checked, with ffmpeg 9.0 on the film above: each command runs; -g 3 gives a keyframe every 3 frames in both codecs (counted with the showinfo line); +faststart moves the MP4’s index (moov) in front of the frames, where without it the index comes last; and the poster line writes the exact frame a browser shows at that time. The quality settings (-crf, -preset) were not compared: raise -crf for a smaller file.
Which codec
Offer both, the WebM first: a browser plays the first <source> it can, and H.264 in MP4 is the one every major browser plays. All three test browsers played both files here. -an drops the audio track, which a scrubbed film never plays; -pix_fmt yuv420p keeps the H.264 file to the colour format browsers decode.
film.html4 lines
<video id="film" muted playsinline preload="auto" poster="poster.jpg">
  <source src="film.webm" type="video/webm">
  <source src="film.mp4" type="video/mp4">
</video>
Poster
Use the frame the scrub starts on, so nothing jumps when the film arrives. -ss 4.98 before -i would give the first frame at or after 4.98 s; on this film that is frame 150, a colourway still, where a browser at 4.98 s shows frame 149. The select line keeps the last frame that starts at or before the time, which is the one on screen. The generator draws the same frame from the video itself and saves it for you.
Also
Decoding cost grows with the picture: a film larger than it is ever shown costs every seek for nothing. For VP8, the recipe’s command also turns off alternate reference frames (-auto-alt-ref 0), which the recipe found had tripled seek times on this film.
05Next step

Use it in your project

scrollVideo has an entry of its own, svg-scroll-draw/video, and the generated code imports it from there. It is not in the script-tag build, so the HTML file imports the ES module from jsDelivr.

067 questions

Scroll video questions

Q1

How do I play a video on scroll, like Apple’s product pages?

Tie the video's currentTime to how far the reader has scrolled. scrollVideo from svg-scroll-draw/video does it in one call: it works out a progress from 0 to 1 over a trigger window, as scroll animations do, and writes currentTime = from + (to − from) × progress on each animation frame that changes it. For the product-page version, put the video in a position: sticky stage inside a tall track and point triggerElement at the track, with start 'top top' and end 'bottom bottom', so the film runs exactly while the stage is stuck. Encode the video with a keyframe every few frames, or it will stutter. The generator above writes all of it.

Q2

Why is my scroll-scrubbed video choppy?

Almost always the keyframes. Every change of scroll is a seek, and a seek decodes from the keyframe before the frame asked for. Measured on this site’s film, left at the encoders’ defaults (2 keyframes in 162 frames), the slowest 5% of seeks took 24–116 ms in Chromium, Firefox and WebKit; with a keyframe every 3 frames, 18 ms or less. At 60 frames a second a frame lasts 16.7 ms. Re-encode with -g 3 (section 04). The other causes: a server that ignores Range requests, a file that has not finished arriving, and a resolution far above what the screen shows.

Q3

What keyframe interval should a scroll scrub video have?

A short one: every 1 to 15 frames. In the measurements on this page, a keyframe every 15 frames kept the slowest 5% of seeks at 17 ms or under, at 0.4–0.6 times the size of a keyframe every 3 frames, and making every frame a keyframe (-g 1) kept the median seek within 2 ms of -g 3 at 2.2–2.6 times the size. Your footage will differ: pick your own file in the generator and watch the seek times.

Q4

Can I scrub a video on scroll with CSS only?

Not a <video>. CSS scroll-driven animations (animation-timeline: scroll() or view()) animate CSS properties, and currentTime is not one, so it takes a few lines of script. What CSS alone can scrub is a sprite sheet: every frame in one image, its background-position stepped with steps() on a scroll timeline, at the cost of sending every frame as a picture.

Q5

Does a scroll-scrubbed video work on iPhone and in Safari?

It can, given the usual attributes and server. Give the video muted, playsinline and preload="auto", serve it from a server that answers byte-range requests (Apple documents them as required for media on iOS), and add an MP4 (H.264) source beside a WebM one for any browser that cannot play WebM, plus a poster. Visitors who have asked their system for reduced motion see the frame at to, held still, instead of a scrub.

Q6

How do I make the video play backwards when scrolling up?

It already does. Scrolling up lowers the progress, and scrollVideo seeks back frame by frame. A backward seek decodes from the keyframe before the frame too, which is why sparse keyframes stutter both ways. To have the film run backwards as the page scrolls down, give from a later time than to, such as from: 5, to: 0.

Q7

How do I keep the video pinned while it scrubs?

Put it in a stage with position: sticky, top: 0 and one window's height, inside a track as tall as the scroll you want plus one window: 400vh gives 300vh of film. The video's own box moves while the stage is stuck, so measure the track: triggerElement: track, trigger: { start: 'top top', end: 'bottom bottom' }. If the stage does not stick, an ancestor probably has overflow set to hidden, auto or scroll. scrollPin from svg-scroll-draw/pin pins with position: fixed instead, with callbacks at both ends.

More on scroll video with svg-scroll-draw 2.23.7: the pinned film recipe, scrollVideo in the docs and the scroll pin generator.