← Overlays

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.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap your <video> (or embed) in an element with data-floating-video-player.

Options

Add any of these attributes to the same element.

data-cornerbottom-right, bottom-left, top-right or top-left. Default bottom-right.
data-widthWidth of the floating player. Default 300 px, less on small screens.
data-marginGap from the edges. Default 16 px.
data-playingOnly float while the video is playing.
data-durationTime to fly into the corner. Default 520 ms.
data-containedFloat inside the scrolling element’s parent instead of the window, for previews. The player must sit inside a scrolling element.
data-autoplayScrolls 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.

1 · Snippet
<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 = '&times;';
      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>
2 · Your HTML
<div data-floating-video-player data-playing>
  <video src="talk.mp4" controls playsinline></video>
</div>