Thumbnail Scrub
Moving the pointer across a thumbnail scrubs through its video, like the previews on video sites. The left edge is the start and the right edge the end, a thin bar along the bottom shows where you are, and it goes back to the first frame when the pointer leaves.
How to use it
- Paste the snippet just before
</head>. - Add
data-thumbnail-scrubto an element with a muted<video>inside. - Or, for a lighter page, fill it with a strip of still frames (images or SVGs) in order. The first one shows at rest.
Options
Add any of these attributes to the same element.
data-length | For still frames: the clip’s length in seconds, shown as a time label. Videos use their own length. Default none, no label. |
data-color | Colour of the progress bar. Default white. |
data-rest | first goes back to the first frame when the pointer leaves; stay keeps the frame it was on. Default first. |
data-autoplay | A pretend pointer sweeps back and forth while nobody’s hovering. Handy for demos. |
For a video, use preload="auto" and a short clip with frequent keyframes so seeking stays quick. On touch screens, drag a finger sideways across the thumbnail; scrolling up and down still works. With reduced motion turned on, there is no autoplay.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Thumbnail Scrub: moving the pointer across a thumbnail scrubs through
// its video, like the previews on video sites. Left edge is the start,
// right edge the end; a thin bar along the bottom shows where you are.
// When the pointer leaves, it goes back to the first frame.
// Usage: <div data-thumbnail-scrub><video src="clip.mp4" muted preload="auto"></video></div>
// or, with a strip of still frames: <div data-thumbnail-scrub>
// <img src="f1.jpg" alt="…"><img src="f2.jpg" alt=""><img src="f3.jpg" alt="">…</div>
// Optional: data-length (the clip's length in seconds, shown as a time
// label for still frames; videos use their own length; default none),
// data-color (bar colour, default #fff), data-rest ("first" goes back to
// the first frame when the pointer leaves, "stay" keeps the last one;
// default first), data-autoplay (a pretend pointer sweeps back and forth
// while nobody is hovering; handy for demos).
// On touch screens, drag a finger sideways across the thumbnail.
var DEFAULTS = { color: '#fff', rest: 'first' };
var css =
'.tsb{position:relative;overflow:hidden;touch-action:pan-y;-webkit-user-select:none;user-select:none}' +
'.tsb-frame{display:block;width:100%;height:100%;object-fit:cover;visibility:hidden}' +
'.tsb-frame+.tsb-frame{position:absolute;inset:0}' +
'.tsb-frame.tsb-on{visibility:visible}' +
'.tsb video.tsb-frame{visibility:visible}' +
'.tsb-bar{position:absolute;left:0;right:0;bottom:0;height:4px;background:rgba(0,0,0,.35);pointer-events:none;opacity:0;transition:opacity .25s}' +
'.tsb-fill{position:absolute;left:0;top:0;bottom:0;width:100%;transform-origin:0 50%;transform:scaleX(0)}' +
'.tsb-time{position:absolute;right:8px;bottom:10px;padding:2px 6px;border-radius:4px;background:rgba(0,0,0,.72);color:#fff;' +
'font:600 12px/1.4 system-ui,sans-serif;font-variant-numeric:tabular-nums;pointer-events:none}' +
'.tsb-live .tsb-bar{opacity:1}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function clock(s) {
s = Math.max(0, Math.round(s));
var m = Math.floor(s / 60), r = s % 60;
return m + ':' + (r < 10 ? '0' : '') + r;
}
function setup(el) {
var d = el.dataset;
var auto = d.autoplay !== undefined && d.autoplay !== 'false';
var stay = (d.rest || DEFAULTS.rest) === 'stay';
var still = reduced();
var video = el.querySelector('video');
var frames = video ? [video] : Array.prototype.slice.call(el.children);
if (!frames.length) return;
el.classList.add('tsb');
frames.forEach(function (f, i) {
f.classList.add('tsb-frame');
if (i) f.setAttribute('aria-hidden', 'true');
});
if (video) { video.muted = true; video.playsInline = true; video.pause(); }
var bar = document.createElement('span');
bar.className = 'tsb-bar';
bar.setAttribute('aria-hidden', 'true');
var fill = document.createElement('span');
fill.className = 'tsb-fill';
fill.style.background = d.color || DEFAULTS.color;
bar.appendChild(fill);
el.appendChild(bar);
var length = +d.length || 0;
var label = null;
if (length || video) {
label = document.createElement('span');
label.className = 'tsb-time';
label.setAttribute('aria-hidden', 'true');
el.appendChild(label);
}
function total() { return video ? (isFinite(video.duration) ? video.duration : 0) : length; }
var shown = -1, pos = 0, live = false;
// Show the spot `f` (0 to 1) along the clip.
function show(f, scrubbing) {
pos = Math.max(0, Math.min(1, f));
fill.style.transform = 'scaleX(' + pos.toFixed(4) + ')';
if (video) {
if (video.readyState >= 1 && total()) video.currentTime = Math.min(total() - 0.05, pos * total());
} else {
var i = Math.min(frames.length - 1, Math.floor(pos * frames.length));
if (i !== shown) {
if (shown >= 0) frames[shown].classList.remove('tsb-on');
frames[i].classList.add('tsb-on');
shown = i;
}
}
if (label) label.textContent = scrubbing ? clock(pos * total()) + ' / ' + clock(total()) : clock(total());
}
function setLive(on) { live = on; el.classList.toggle('tsb-live', on); }
function along(e) {
var b = el.getBoundingClientRect();
return (e.clientX - b.left) / b.width;
}
var hover = false, touched = 0;
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
hover = true; setLive(true); show(along(e), true);
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') touched = Date.now();
else hover = true;
setLive(true);
show(along(e), true);
});
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
touched = Date.now(); setLive(true); show(along(e), true);
});
function leave(e) {
if (e.pointerType === 'touch') { touched = Date.now(); return; }
hover = false; setLive(false);
show(stay ? pos : 0, false);
}
el.addEventListener('pointerleave', leave);
el.addEventListener('pointercancel', function () { touched = Date.now(); });
if (video) video.addEventListener('loadedmetadata', function () { show(pos, live); });
show(0, false);
// Autoplay: a pretend pointer glides to and fro across the thumbnail.
if (auto && !still && typeof anime !== 'undefined') {
var visible = false, fake = { f: 0 };
var sweep = function () {
if (!visible || hover || Date.now() - touched < 3000) { setTimeout(sweep, 800); return; }
setLive(true);
anime.animate(fake, {
f: [0, 1], duration: 2600, ease: 'inOut(2)',
onUpdate: function () { if (!hover && Date.now() - touched > 3000) show(fake.f, true); },
onComplete: function () {
setTimeout(function () {
if (hover) { setTimeout(sweep, 800); return; }
anime.animate(fake, {
f: [1, 0], duration: 1100, ease: 'inOut(3)',
onUpdate: function () { if (!hover && Date.now() - touched > 3000) show(fake.f, true); },
onComplete: function () {
if (!hover) { setLive(false); show(0, false); }
setTimeout(sweep, 1200);
},
});
}, 500);
},
});
};
new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; }).observe(el);
setTimeout(sweep, 500);
}
}
function init() {
document.querySelectorAll('[data-thumbnail-scrub]').forEach(function (el) {
if (el._tsb) return;
el._tsb = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div class="thumb" data-thumbnail-scrub>
<video src="clip.mp4" muted preload="auto"></video>
</div>
<!-- with still frames: -->
<div class="thumb" data-thumbnail-scrub data-length="12">
<img src="frame-1.jpg" alt="A sunset timelapse">
<img src="frame-2.jpg" alt="">
<img src="frame-3.jpg" alt="">
</div>