Scroll-scrubbed Video
The video pins in place and its playhead follows the scroll: scroll down to play it, back up to rewind, stop to hold the frame. The playhead eases after the scroll so it never stutters. It scrubs animated SVGs too, like the sunrise here.
Scroll inside ↓
How to use it
- Paste the snippet just before
</head>. - Add
data-scroll-scrubbed-videoto a<video>withmuted playsinline preload="auto", or to a wrapper around a video or an animated SVG.
Options
Add any of these attributes to the same element.
data-length | How many screen heights of scrolling the video takes while pinned. Default 1.5; 0 scrubs it as it passes, without pinning. |
data-smooth | How quickly the playhead catches up with the scroll, from 0 to 1. Default 0.2; 1 follows it exactly. |
data-timeline | Show a thin playhead bar and the time along the bottom. |
data-duration | For an SVG: its length in seconds. Default: the longest of its animations. |
Seeking is smoothest when every frame is a keyframe: encode with ffmpeg -i in.mp4 -g 1 -an out.mp4 and keep clips short. With reduced motion a video shows its poster and an SVG its last frame. Works inside scrolling boxes too.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Scroll-scrubbed Video: a video's playhead follows the scroll position, so
// scrolling down plays it and scrolling up rewinds it. The video pins in
// place while it plays. Also scrubs an animated SVG (SMIL animations).
// Usage: <video data-scroll-scrubbed-video src="clip.mp4" muted playsinline
// preload="auto"></video>
// or on a wrapper around a <video> or an animated <svg>.
// Optional: data-length (how many screen heights of scrolling the video
// takes while pinned, default 1.5; 0 scrubs it as it passes without
// pinning), data-smooth (0 to 1: how quickly the playhead catches up with
// the scroll, default 0.2; 1 to follow it exactly), data-duration (seconds,
// for an SVG; default the longest of its animations), data-timeline (show
// a thin playhead bar and the time along the bottom).
// Works inside scrolling boxes too: it follows the nearest scrolling parent.
var DEFAULTS = { length: 1.5, smooth: 0.2 };
var css =
'.ssv{position:relative}' +
'.ssv-pin{position:sticky;top:0;display:flex;align-items:center;justify-content:center}' +
'.ssv-media{position:relative;max-width:100%}' +
'.ssv-media>video,.ssv-media>svg{display:block;width:100%;height:auto}' +
'.ssv-bar{position:absolute;left:12px;right:12px;bottom:12px;display:flex;align-items:center;gap:10px;color:#fff;font:600 12px/1 system-ui,sans-serif;font-variant-numeric:tabular-nums;text-shadow:0 1px 3px rgba(0,0,0,.5);pointer-events:none}' +
'.ssv-track{position:relative;flex:1;height:4px;border-radius:4px;background:rgba(255,255,255,.35);overflow:hidden}' +
'.ssv-fill{position:absolute;inset:0;background:#fff;transform-origin:0 50%;transform:scaleX(0)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, fallback) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : fallback; }
function scroller(el) {
for (var p = el.parentElement; p && p !== document.body; p = p.parentElement) {
var o = getComputedStyle(p).overflowY;
if (o === 'auto' || o === 'scroll') return p;
}
return null;
}
function clock(s) {
s = Math.max(0, Math.floor(s));
return Math.floor(s / 60) + ':' + (s % 60 < 10 ? '0' : '') + (s % 60);
}
// The longest begin + dur among an SVG's animations, in seconds.
function svgLength(svg) {
var longest = 0;
svg.querySelectorAll('animate,animateTransform,animateMotion,set').forEach(function (a) {
var dur = parseFloat(a.getAttribute('dur')) || 0;
var begin = parseFloat(a.getAttribute('begin')) || 0;
if (/ms$/.test(a.getAttribute('dur') || '')) dur /= 1000;
longest = Math.max(longest, begin + dur);
});
return longest || 1;
}
function setup(el) {
var d = el.dataset;
var media = el.matches('video,svg') ? el : el.querySelector('video,svg');
if (!media) return;
var length = Math.max(0, num(d.length, DEFAULTS.length));
var smooth = Math.min(1, Math.max(0.01, num(d.smooth, DEFAULTS.smooth)));
var isVideo = media.tagName === 'VIDEO';
var still = reduced();
var box = scroller(el);
// Wrap: section (grows taller) > sticky pin > the element.
var section = document.createElement('div');
section.className = 'ssv';
var pin = document.createElement('div');
pin.className = 'ssv-pin';
el.parentNode.insertBefore(section, el);
section.appendChild(pin);
var frame = el;
if (el === media) {
frame = document.createElement('div');
pin.appendChild(frame);
frame.appendChild(el);
} else {
pin.appendChild(el);
}
frame.classList.add('ssv-media');
var duration = isVideo ? 0 : num(d.duration, svgLength(media));
if (isVideo) {
media.muted = true;
media.playsInline = true;
media.pause();
var onMeta = function () { duration = media.duration || 0; seek(true); };
if (media.readyState >= 1) onMeta();
else media.addEventListener('loadedmetadata', onMeta);
// Catch up with the scroll once a slow seek finishes.
media.addEventListener('seeked', function () { seek(); });
} else if (media.pauseAnimations) {
media.pauseAnimations();
}
var fill = null, time = null;
if (d.timeline !== undefined && d.timeline !== 'false') {
var bar = document.createElement('div');
bar.className = 'ssv-bar';
bar.setAttribute('aria-hidden', 'true');
bar.innerHTML = '<span class="ssv-track"><span class="ssv-fill"></span></span><span class="ssv-time"></span>';
frame.appendChild(bar);
fill = bar.querySelector('.ssv-fill');
time = bar.querySelector('.ssv-time');
}
var viewH = 0, pos = 0, target = 0, raf = 0, queued = false;
function measure() {
viewH = box ? box.clientHeight : window.innerHeight;
var pinned = length > 0 && !still;
pin.style.position = pinned ? '' : 'static';
pin.style.height = pinned ? viewH + 'px' : '';
section.style.height = pinned ? viewH * (1 + length) + 'px' : '';
}
function seek(force) {
if (!duration) return;
var t = Math.min(duration - 0.001, pos * duration);
if (isVideo) {
// Skip while the last seek is still decoding, so it never falls behind.
if (force || (!media.seeking && Math.abs(media.currentTime - t) > 0.01)) media.currentTime = t;
} else if (media.setCurrentTime) {
media.setCurrentTime(t);
}
if (fill) {
fill.style.transform = 'scaleX(' + pos.toFixed(4) + ')';
time.textContent = clock(t) + ' / ' + clock(duration);
}
}
function progress() {
var r = section.getBoundingClientRect();
var top = 0;
if (box) top = box.getBoundingClientRect().top + box.clientTop;
var p = length > 0
? (top - r.top) / (viewH * length)
: (top + viewH - r.top) / (viewH + r.height);
return Math.max(0, Math.min(1, p));
}
function tick() {
raf = 0;
pos += (target - pos) * smooth;
if (Math.abs(target - pos) < 0.0005) pos = target;
seek();
if (pos !== target) raf = requestAnimationFrame(tick);
}
function update() {
queued = false;
target = progress();
if (!raf) raf = requestAnimationFrame(tick);
}
function onScroll() {
if (queued) return;
queued = true;
requestAnimationFrame(update);
}
measure();
if (still) {
// A still frame: the end of an SVG, the poster or first frame of a video.
if (!isVideo) { pos = 1; seek(); }
return;
}
pos = target = progress();
seek(true);
(box || window).addEventListener('scroll', onScroll, { passive: true });
window.addEventListener('resize', function () { measure(); onScroll(); });
}
function init() {
document.querySelectorAll('[data-scroll-scrubbed-video]').forEach(function (el) {
if (el._ssv) return;
el._ssv = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><video data-scroll-scrubbed-video src="product.mp4"
muted playsinline preload="auto"></video>
<!-- with a playhead bar, over three screens of scrolling: -->
<video data-scroll-scrubbed-video data-timeline data-length="3"
src="product.mp4" muted playsinline preload="auto"></video>