← Hover & cursor

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.

Sunset over the lake, a timelapseAnimacious · 4.2K views

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-thumbnail-scrub to an element with a muted <video> inside.
  3. 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-lengthFor still frames: the clip’s length in seconds, shown as a time label. Videos use their own length. Default none, no label.
data-colorColour of the progress bar. Default white.
data-restfirst goes back to the first frame when the pointer leaves; stay keeps the frame it was on. Default first.
data-autoplayA 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.

1 · Snippet
<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>
2 · Your HTML
<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>