Video Hover Preview
A video thumbnail that plays a muted preview while the pointer is over it, with a thin progress line and a countdown in the corner. Move away and it fades back to the still. On phones a tap starts and stops it.
How to use it
- Paste the snippet just before
</head>. - Add
data-video-hover-previewto a box holding two things: the still image first, then the preview, a muted<video>. - Give the box a size, such as a width and an
aspect-ratio. The play button, countdown and progress line are added for you.
Options
Add any of these attributes to the same element.
data-fade | How long the preview takes to fade in and out. Default 300 ms. |
data-restart="false" | Carry on from where it stopped, instead of starting from the beginning each time. |
data-progress="false" | Hide the progress line. |
data-time="false" | Hide the countdown. |
data-length | Length in seconds, for a preview that isn’t a video (below). Default 6. |
data-autoplay | Milliseconds: play and stop on a timer until someone interacts. |
data-delay | Wait before the first autoplay, to stagger several. Default 600 ms. |
Use preload="none" on the video so nothing downloads until someone hovers. The preview can also be any element with CSS or SVG animations, like the clips in this demo: they’re paused, and the script plays them along with the countdown. Keyboard focus plays it too. From code: animacious.videoPreview(element, true).
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Video Hover Preview: a thumbnail that plays a muted preview while the
// pointer is over it, with a progress line and a countdown, then fades
// back to the still when the pointer leaves. On touch screens a tap
// starts and stops it; keyboard focus plays it too.
// Usage: <div data-video-hover-preview>
// <img src="poster.jpg" alt="Sunset sail">
// <video src="preview.mp4" muted playsinline loop preload="none"></video>
// </div>
// The first child is the still, the second the preview: a <video>, or any
// element with CSS or SVG animations, which are paused and played for you.
// Optional: data-length (seconds, for a preview that isn't a video,
// default 6), data-fade (ms, default 300), data-restart="false" (carry on
// from where it stopped instead of starting again), data-progress="false"
// (no progress line), data-time="false" (no countdown badge),
// data-autoplay (ms: play and stop on a timer until someone interacts),
// data-delay (ms before the first autoplay, default 600).
// Script use: animacious.videoPreview(el, true|false).
var DEFAULTS = { length: 6, fade: 300, delay: 600 };
var css =
'.vhp{position:relative;overflow:hidden;isolation:isolate;-webkit-tap-highlight-color:transparent}' +
'.vhp>.vhp-poster,.vhp>.vhp-preview{display:block;width:100%;height:100%;object-fit:cover}' +
'.vhp>.vhp-preview{position:absolute;inset:0;opacity:0}' +
'.vhp-play{position:absolute;left:50%;top:50%;z-index:2;width:42px;height:42px;margin:-21px 0 0 -21px;border-radius:50%;background:rgba(15,15,25,.5);box-shadow:0 0 0 2px rgba(255,255,255,.75);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px);pointer-events:none}' +
'.vhp-play::after{content:"";position:absolute;left:16px;top:13px;border-style:solid;border-width:8px 0 8px 13px;border-color:transparent transparent transparent #fff}' +
'.vhp-time{position:absolute;right:8px;bottom:9px;z-index:2;padding:2px 6px;border-radius:4px;background:rgba(0,0,0,.72);color:#fff;font:600 11px/1.3 system-ui,sans-serif;font-variant-numeric:tabular-nums;pointer-events:none}' +
'.vhp-bar{position:absolute;left:0;right:0;bottom:0;z-index:2;height:3px;background:rgba(255,255,255,.3);opacity:0;pointer-events:none}' +
'.vhp-bar>i{display:block;height:100%;background:#ef4444;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 clock(sec) {
sec = Math.max(0, Math.ceil(sec));
var s = sec % 60;
return Math.floor(sec / 60) + ':' + (s < 10 ? '0' : '') + s;
}
function setup(el) {
var d = el.dataset;
var kids = Array.prototype.slice.call(el.children);
var preview = el.querySelector(':scope > video') || kids[1];
var poster = kids[0] === preview ? null : kids[0];
if (!preview) return;
el.classList.add('vhp');
if (poster) poster.classList.add('vhp-poster');
preview.classList.add('vhp-preview');
preview.setAttribute('aria-hidden', 'true');
var isVideo = preview.tagName === 'VIDEO';
var s = el._vhpS = {
preview: preview, isVideo: isVideo, playing: false, t: 0, raf: 0, last: 0,
len: +d.length || DEFAULTS.length, visible: false, timer: 0, fadeAnim: null, anims: []
};
if (isVideo) {
preview.muted = true;
preview.playsInline = true;
preview.loop = true;
preview.setAttribute('muted', '');
preview.setAttribute('playsinline', '');
preview.addEventListener('loadedmetadata', function () {
if (isFinite(preview.duration)) { s.len = preview.duration; paint(el); }
});
} else {
// Hold every CSS animation inside the preview still; the script moves the clock.
s.anims = preview.getAnimations ? preview.getAnimations({ subtree: true }) : [];
s.anims.forEach(function (a) { a.pause(); a.currentTime = 0; });
if (preview.pauseAnimations) { preview.pauseAnimations(); preview.setCurrentTime(0); }
}
var play = document.createElement('span');
play.className = 'vhp-play';
play.setAttribute('aria-hidden', 'true');
el.appendChild(play);
s.play = play;
if (d.time !== 'false') {
s.time = document.createElement('span');
s.time.className = 'vhp-time';
s.time.setAttribute('aria-hidden', 'true');
el.appendChild(s.time);
}
if (d.progress !== 'false') {
s.bar = document.createElement('span');
s.bar.className = 'vhp-bar';
s.bar.setAttribute('aria-hidden', 'true');
s.bar.appendChild(document.createElement('i'));
el.appendChild(s.bar);
}
paint(el);
var interacted = function () { if (s.timer) { clearInterval(s.timer); s.timer = 0; } };
el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') { interacted(); toggle(el, true); } });
el.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') toggle(el, false); });
var pressing = false;
el.addEventListener('pointerdown', function () { pressing = true; });
el.addEventListener('pointerup', function (e) {
pressing = false;
if (e.pointerType !== 'mouse') { interacted(); toggle(el, !s.playing); }
});
el.addEventListener('focusin', function () { if (!pressing) { interacted(); toggle(el, true); } });
el.addEventListener('focusout', function () { if (!pressing) toggle(el, false); });
new IntersectionObserver(function (en) {
s.visible = en[0].isIntersecting;
if (s.visible) kick(el);
else if (s.isVideo) preview.pause();
}, { threshold: 0.2 }).observe(el);
if (d.autoplay !== undefined && d.autoplay !== 'false' && !reduced()) {
var every = +d.autoplay || 3000;
setTimeout(function () {
if (s.autoOff) return;
if (s.visible) toggle(el, true);
s.timer = setInterval(function () { if (s.visible) toggle(el, !s.playing); }, every);
}, d.delay !== undefined ? +d.delay : DEFAULTS.delay);
var off = function () { s.autoOff = true; interacted(); };
el.addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') off(); });
el.addEventListener('pointerdown', off);
}
}
function paint(el) {
var s = el._vhpS;
if (s.isVideo) s.t = s.preview.currentTime || 0;
else {
s.anims.forEach(function (a) { a.currentTime = s.t * 1000; });
if (s.preview.setCurrentTime) s.preview.setCurrentTime(s.t);
}
if (s.time) s.time.textContent = clock(s.playing || s.t > 0 ? s.len - s.t : s.len);
if (s.bar) s.bar.firstChild.style.transform = 'scaleX(' + Math.min(1, s.t / s.len).toFixed(4) + ')';
}
function kick(el) {
var s = el._vhpS;
if (s.raf || !s.visible || !s.playing) return;
if (s.isVideo) { var p = s.preview.play(); if (p && p.catch) p.catch(function () {}); }
s.last = performance.now();
s.raf = requestAnimationFrame(function tick(now) {
s.raf = 0;
if (!s.playing || !s.visible) return;
if (!s.isVideo) s.t = (s.t + Math.min(0.05, (now - s.last) / 1000)) % s.len;
s.last = now;
paint(el);
s.raf = requestAnimationFrame(tick);
});
}
function toggle(el, on) {
var s = el._vhpS;
if (!s || s.playing === on) return;
s.playing = on;
var fade = el.dataset.fade !== undefined ? +el.dataset.fade : DEFAULTS.fade;
if (s.fadeAnim) s.fadeAnim.pause();
var targets = [s.preview, s.bar].filter(Boolean);
if (on) {
if (el.dataset.restart !== 'false') { s.t = 0; if (s.isVideo) s.preview.currentTime = 0; paint(el); }
kick(el);
}
s.fadeAnim = anime.animate(targets, {
opacity: on ? 1 : 0,
duration: fade,
ease: 'out(2)',
onComplete: function () {
if (on) return;
if (s.isVideo) s.preview.pause();
if (el.dataset.restart !== 'false') { s.t = 0; if (s.isVideo) s.preview.currentTime = 0; }
paint(el);
}
});
anime.animate(s.play, { opacity: on ? 0 : 1, scale: on ? 0.6 : 1, duration: fade, ease: 'out(2)' });
}
function init() {
document.querySelectorAll('[data-video-hover-preview]').forEach(function (el) {
if (el._vhp) return;
el._vhp = true;
setup(el);
});
}
window.animacious = window.animacious || {};
window.animacious.videoPreview = function (el, on) {
var s = el._vhpS;
if (!s) return;
if (s.timer) { clearInterval(s.timer); s.timer = 0; }
s.autoOff = true;
toggle(el, on === undefined ? !s.playing : !!on);
};
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-video-hover-preview style="width: 320px; aspect-ratio: 16 / 9">
<img src="poster.jpg" alt="Sunset sail">
<video src="preview.mp4" muted playsinline loop preload="none"></video>
</div>