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
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.
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.
Window · 360px tall · scroll it · the stage scrolls
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.
// 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.
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.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 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;start: 'top top' and end: 'bottom bottom': the stretch the stage is stuck.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(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.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.
| Option | Type | Default | What 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. |
| triggerElement | Element | the video | Measure 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. |
| from | number | 0 | The time, in seconds, at progress 0. |
| to | number | the duration | The 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). |
| once | boolean | false | Keep 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). |
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.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 it | Why |
|---|---|
| muted, playsinline | scrollVideo 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 requests | Browsers 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. |
| poster | Shown 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 controls | scrollVideo 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. |
<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.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.
| Encode | Keyframes | Size | Chromium ms | Firefox ms | WebKit ms |
|---|---|---|---|---|---|
| VP8 -g 3 | 54 / 162 | 1628 KB | 8 · 11 | 4 · 5 | 6 · 12 |
| VP9 -g 1 | 162 / 162 | 2922 KB | 6 · 7 | 3 · 5 | 2 · 4 |
| VP9 -g 3 | 54 / 162 | 1112 KB | 4 · 6 | 4 · 5 | 3 · 4 |
| VP9 -g 15 | 11 / 162 | 491 KB | 6 · 13 | 7 · 10 | 7 · 17 |
| VP9 default | 2 / 162 | 383 KB | 26 · 72 | 25 · 58 | 34 · 116 |
| H.264 -g 1 | 162 / 162 | 2073 KB | 5 · 7 | 3 · 4 | 1 · 2 |
| H.264 -g 3 | 54 / 162 | 954 KB | 6 · 18 | 3 · 7 | 1 · 2 |
| H.264 -g 15 | 12 / 162 | 573 KB | 7 · 11 | 4 · 6 | 1 · 3 |
| H.264 default | 2 / 162 | 487 KB | 37 · 79 | 18 · 35 | 2 · 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.
# 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-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.<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.<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>-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.-auto-alt-ref 0), which the recipe found had tripled seek times on this film.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.
Scroll video questions
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.
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.
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.
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.
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.
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.
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.