Floating Video Player
Scroll past a video and it shrinks down into a little player floating in the corner, so it keeps you company while you read on. Scroll back up and it flies home into its place in the page. A close button tucks it away.
▶ 1:24
How to use it
- Paste the snippet just before
</head>. - Wrap your
<video>(or embed) in an element withdata-floating-video-player.
Options
Add any of these attributes to the same element.
data-corner | bottom-right, bottom-left, top-right or top-left. Default bottom-right. |
data-width | Width of the floating player. Default 300 px, less on small screens. |
data-margin | Gap from the edges. Default 16 px. |
data-playing | Only float while the video is playing. |
data-duration | Time to fly into the corner. Default 520 ms. |
data-contained | Float inside the scrolling element’s parent instead of the window, for previews. The player must sit inside a scrolling element. |
data-autoplay | Scrolls that element down and back by itself while in view, until someone touches it. |
The video keeps playing as it moves, because it’s the same element, not a copy, and its place in the page is held open so nothing jumps. Closing the floating player pauses the video. It fires videofloat and videodock events.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Floating Video Player: scroll past a video and it shrinks into a small
// player that floats in the corner, so it keeps playing while you read on.
// Scroll back up and it flies home into its place in the page.
// Usage:
// <div data-floating-video-player>
// <video src="talk.mp4" controls playsinline></video>
// </div>
// Optional: data-corner ("bottom-right" default, "bottom-left", "top-right",
// "top-left"), data-width (width of the floating player in px, default 300;
// never more than the screen allows), data-margin (gap from the edges in
// px, default 16), data-playing (only float while the <video> inside is
// playing), data-duration (ms, default 520), data-contained (float inside
// the scrolling element's parent instead of the window, for previews; the
// player must sit inside a scrolling element), data-autoplay (scrolls that
// element down and back by itself while in view, until someone touches it).
// The floating player has a close button; closing it pauses the video and
// leaves it in its place for when you scroll back.
// Events: "videofloat" and "videodock" on the element.
var DEFAULTS = { width: 300, margin: 16, duration: 520, hold: 2600, corner: 'bottom-right' };
var css =
'.fvp{position:relative}' +
'.fvp-player{position:relative;transform-origin:0 0}' +
'.fvp-float{position:fixed;z-index:9050;overflow:hidden;border-radius:12px;box-shadow:0 18px 44px rgba(10,8,25,.35),0 0 0 1px rgba(255,255,255,.08)}' +
'.fvp-host{transform:translateZ(0)}' +
'.fvp-x{position:absolute;top:6px;right:6px;z-index:3;display:none;place-items:center;width:28px;height:28px;padding:0;border:0;border-radius:50%;background:rgba(10,8,25,.6);color:#fff;font:400 18px/1 system-ui,sans-serif;cursor:pointer;backdrop-filter:blur(4px)}' +
'.fvp-float .fvp-x{display:grid}' +
'.fvp-x:focus-visible{outline:2px solid #fff;outline-offset:1px}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function on(v) { return v !== undefined && v !== 'false'; }
function scroller(node) {
for (var n = node.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
var o = getComputedStyle(n).overflowY;
if (o === 'auto' || o === 'scroll') return n;
}
return null;
}
function setup(el) {
var d = el.dataset;
var v = {
el: el, floating: false, out: false, closed: false,
corner: d.corner || DEFAULTS.corner,
width: +d.width || DEFAULTS.width,
margin: d.margin !== undefined ? +d.margin : DEFAULTS.margin,
duration: +d.duration || DEFAULTS.duration,
contained: on(d.contained),
deco: !!el.closest('[aria-hidden="true"]')
};
el._fvp = v;
el.classList.add('fvp');
var player = document.createElement('div');
player.className = 'fvp-player';
while (el.firstChild) player.appendChild(el.firstChild);
el.appendChild(player);
v.player = player;
v.video = player.querySelector('video');
v.playingOnly = on(d.playing) && !!v.video;
var x = document.createElement(v.deco ? 'span' : 'button');
x.className = 'fvp-x';
x.innerHTML = '×';
if (!v.deco) { x.type = 'button'; x.setAttribute('aria-label', 'Close floating player'); }
player.appendChild(x);
x.addEventListener('click', function (e) {
e.stopPropagation();
if (v.stopAuto) v.stopAuto();
close(v);
});
v.root = v.contained ? scroller(el) : null;
v.host = v.root ? v.root.parentElement : null;
if (v.host) v.host.classList.add('fvp-host');
new IntersectionObserver(function (entries) {
var e = entries[entries.length - 1];
var top = e.rootBounds ? e.rootBounds.top : 0;
v.out = e.intersectionRatio < 0.35 && e.boundingClientRect.top < top + 10;
if (!v.out) v.closed = false;
update(v);
}, { root: v.root, threshold: [0, 0.35, 0.6, 1] }).observe(el);
if (v.video) {
v.video.addEventListener('play', function () { update(v); });
v.video.addEventListener('pause', function () { update(v); });
}
window.addEventListener('resize', function () { if (v.floating) place(v); });
if (on(d.autoplay) && v.root && !reduced()) autoplay(v, +d.autoplay || DEFAULTS.hold);
return v;
}
function update(v) {
var want = v.out && !v.closed && (!v.playingOnly || !v.video.paused);
if (want && !v.floating) float(v);
else if (!want && v.floating && !v.closed) dock(v);
}
// Size and position of the floating player, in the coordinates it uses.
function place(v) {
var area = v.host ? { w: v.host.clientWidth, h: v.host.clientHeight } : { w: window.innerWidth, h: window.innerHeight };
var m = v.margin, ratio = v.el.offsetHeight / Math.max(1, v.el.offsetWidth);
var w = Math.min(v.width, area.w * (v.host ? 0.5 : 1) - m * 2);
var h = w * ratio;
var left = v.corner.indexOf('left') > -1 ? m : area.w - w - m;
var top = v.corner.indexOf('top') > -1 ? m : area.h - h - m;
var s = v.player.style;
s.left = left + 'px'; s.top = top + 'px'; s.width = w + 'px'; s.height = h + 'px';
}
function flip(v, from, ease) {
anime.utils.remove(v.player);
anime.utils.set(v.player, { x: 0, y: 0, scaleX: 1, scaleY: 1, opacity: 1 });
var to = v.player.getBoundingClientRect();
if (reduced() || !to.width) return;
anime.animate(v.player, {
x: [from.left - to.left, 0], y: [from.top - to.top, 0],
scaleX: [from.width / to.width, 1], scaleY: [from.height / to.height, 1],
ease: ease
});
}
function float(v) {
var from = v.player.getBoundingClientRect();
v.floating = true;
v.el.style.height = v.el.offsetHeight + 'px';
v.player.classList.add('fvp-float');
place(v);
v.el.dispatchEvent(new CustomEvent('videofloat', { bubbles: true }));
flip(v, from, anime.spring({ bounce: 0.22, duration: v.duration }));
}
function dock(v) {
var from = v.player.getBoundingClientRect();
v.floating = false;
reset(v);
v.el.dispatchEvent(new CustomEvent('videodock', { bubbles: true }));
flip(v, from, anime.spring({ bounce: 0.15, duration: v.duration }));
}
function reset(v) {
v.player.classList.remove('fvp-float');
var s = v.player.style;
s.left = s.top = s.width = s.height = '';
v.el.style.height = '';
}
function close(v) {
if (!v.floating) return;
v.closed = true;
if (v.video && !v.video.paused) v.video.pause();
var p = v.player;
if (p.contains(document.activeElement)) document.activeElement.blur();
var done = function () {
if (!v.closed) return;
v.floating = false;
reset(v);
anime.utils.set(p, { opacity: 1, scale: 1, x: 0, y: 0 });
};
anime.utils.remove(p);
if (reduced()) return done();
anime.animate(p, { opacity: 0, scale: 0.85, duration: 260, ease: 'in(2)', onComplete: done });
}
// Demo mode: scroll down past the video, wait, and scroll back.
function autoplay(v, hold) {
var sc = v.root, timers = [], anim = null, visible = false, live = true;
function later(fn, ms) { timers.push(setTimeout(fn, ms)); }
function stop() { timers.forEach(clearTimeout); timers = []; if (anim) anim.pause(); anim = null; }
function glide(to, ms, then) {
var o = { v: sc.scrollTop };
anim = anime.animate(o, { v: to, duration: ms, ease: 'inOut(3)', onUpdate: function () { sc.scrollTop = o.v; }, onComplete: then });
}
function run() {
stop();
if (!visible || !live) return;
later(function () {
glide(sc.scrollHeight - sc.clientHeight, 1800, function () {
later(function () { glide(0, 1500, function () { later(run, 400); }); }, hold);
});
}, 1100);
}
v.stopAuto = function () { if (!live) return; live = false; stop(); };
['wheel', 'touchstart', 'pointerdown', 'keydown'].forEach(function (t) {
sc.addEventListener(t, function (e) { if (e.isTrusted) v.stopAuto(); }, { passive: true });
});
new IntersectionObserver(function (entries) {
visible = entries[entries.length - 1].isIntersecting;
if (visible) run(); else stop();
}, { threshold: 0.3 }).observe(v.host || sc);
}
function init() {
document.querySelectorAll('[data-floating-video-player]').forEach(function (el) {
if (el._fvp) return;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><div data-floating-video-player data-playing>
<video src="talk.mp4" controls playsinline></video>
</div>