← Hover & cursor

Image Trail

Move the pointer across the area and it deals out a trail of photos behind it, each one sliding out from the last and tipping slightly, before shrinking and fading away.

Move to explore

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-image-trail to the area.
  3. Put the photos inside it, each marked data-trail-image. They’re hidden and dealt out in turn.

Options

Add any of these attributes to the same element.

data-sizeWidth of each photo, in px. Default a quarter of the area, up to 150.
data-ratioHeight of each photo over its width. Default 1.25.
data-spacingHow far the pointer travels between photos, in px. Default 0.7 times the width.
data-lifeHow long each photo stays before fading. Default 650 ms.
data-autoplayA pretend pointer roams the area while a real one isn’t in it. Handy for demos and touch screens.

The photos sit over the area without catching clicks. On touch screens a tap deals a photo where your finger lands. With reduced motion turned on, there’s no trail.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Image Trail: move the pointer across an area and it deals out a trail of
    // photos behind it, each one sliding out from the last, tipping slightly,
    // then shrinking and fading away.
    // Usage:
    //   <section data-image-trail>
    //     <h2>Move to explore</h2>
    //     <img data-trail-image src="one.jpg" alt="">
    //     <img data-trail-image src="two.jpg" alt="">
    //   </section>
    // The children marked data-trail-image are hidden and dealt out in turn.
    // Optional: data-size (photo width, px, default a quarter of the area up to
    // 150), data-ratio (photo height over width, default 1.25), data-spacing
    // (px the pointer travels between photos, default 0.7 times the width),
    // data-life (ms each photo stays before fading, default 650), data-autoplay
    // (a pretend pointer roams the area while a real one isn't in it; handy for
    // demos and touch screens).
    // The photos sit over the area without catching clicks.
    var DEFAULTS = { ratio: 1.25, life: 650, max: 14 };

    var css =
      '.imt{position:relative;overflow:hidden}' +
      '.imt-src{position:absolute!important;left:0;top:0;width:2px!important;height:2px!important;opacity:0;pointer-events:none;overflow:hidden}' +
      '.imt-layer{position:absolute;inset:0;z-index:2;pointer-events:none;overflow:hidden}' +
      '.imt-item{position:absolute;left:0;top:0;overflow:hidden;border-radius:10px;background:#ddd;' +
      'box-shadow:0 12px 28px rgba(20,10,40,.28);will-change:transform,opacity}' +
      '.imt-item>*{display:block;width:100%!important;height:100%!important;object-fit:cover;max-width:none;margin:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
    function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }

    function setup(el) {
      var d = el.dataset;
      var sources = [].slice.call(el.querySelectorAll('[data-trail-image]'));
      if (!sources.length) return;
      var ratio = num(d.ratio, DEFAULTS.ratio);
      var life = num(d.life, DEFAULTS.life);
      var auto = d.autoplay !== undefined && d.autoplay !== 'false';
      el.classList.add('imt');
      sources.forEach(function (s) { s.classList.add('imt-src'); s.setAttribute('aria-hidden', 'true'); });

      var layer = document.createElement('div');
      layer.className = 'imt-layer';
      layer.setAttribute('aria-hidden', 'true');
      el.appendChild(layer);

      var w = 0, h = 0, W = 0, H = 0, spacing = 0;
      function measure() {
        w = el.clientWidth; h = el.clientHeight;
        W = num(d.size, Math.round(Math.min(150, Math.max(56, Math.min(w * 0.25, h * 0.42)))));
        H = Math.round(W * ratio);
        spacing = num(d.spacing, W * 0.7);
      }
      measure();
      new ResizeObserver(measure).observe(el);

      var n = 0, live = 0, z = 1, last = null, prev = null;

      function spawn(x, y) {
        if (live >= DEFAULTS.max) return;
        live++;
        var item = document.createElement('div');
        item.className = 'imt-item';
        item.style.width = W + 'px';
        item.style.height = H + 'px';
        item.style.zIndex = z++;
        var copy = sources[n++ % sources.length].cloneNode(true);
        copy.classList.remove('imt-src');
        copy.removeAttribute('data-trail-image');
        copy.removeAttribute('id');
        item.appendChild(copy);
        layer.appendChild(item);
        var from = prev || { x: x, y: y };
        prev = { x: x, y: y };
        var tilt = Math.random() * 16 - 8;
        // Slide out from the last photo to the pointer, then sink away.
        anime.animate(item, {
          x: [from.x - W / 2, x - W / 2],
          y: [from.y - H / 2, y - H / 2],
          rotate: [tilt * 0.3, tilt],
          scale: [{ from: 0.55, to: 1, duration: 450, ease: 'out(3)' }],
          duration: 650,
          ease: 'out(3)'
        });
        anime.animate(item, {
          opacity: [{ from: 1, to: 1, duration: life }, { to: 0, duration: 500, ease: 'in(2)' }],
          onComplete: function () { item.remove(); live--; }
        });
        anime.animate(copy, { scale: [1.25, 1], duration: 900, ease: 'out(3)' });
        // Older photos shrink as they fade.
        setTimeout(function () {
          anime.animate(item, { scale: 0.75, y: '+=14', duration: 500, ease: 'in(2)' });
        }, life);
      }

      function moveTo(x, y) {
        if (!last) { last = { x: x, y: y }; return; }
        var dist = Math.hypot(x - last.x, y - last.y);
        if (dist > Math.max(w, h)) { last = { x: x, y: y }; prev = null; return; }
        if (dist >= spacing) {
          spawn(x, y);
          last = { x: x, y: y };
        }
      }
      function local(e) {
        var b = el.getBoundingClientRect();
        return [e.clientX - b.left, e.clientY - b.top];
      }

      var real = false, hold = 0;
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType === 'touch') hold = Date.now() + 2500;
        else real = true;
        var p = local(e);
        moveTo(p[0], p[1]);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch') return;
        real = false; last = null; prev = null;
      });
      // Touch: a tap deals a photo where the finger lands.
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType !== 'touch') return;
        hold = Date.now() + 2500;
        var p = local(e);
        spawn(p[0], p[1]);
        last = { x: p[0], y: p[1] };
      });

      if (auto) {
        var t = Math.random() * 6;
        var visible = false, raf = 0;
        var frame = function () {
          raf = 0;
          if (!visible) return;
          if (!real && Date.now() >= hold) {
            t += 0.019;
            moveTo(w * (0.5 + 0.38 * Math.sin(t * 1.2)), h * (0.5 + 0.3 * Math.sin(t * 2.3 + 0.5)));
          }
          raf = requestAnimationFrame(frame);
        };
        new IntersectionObserver(function (entries) {
          visible = entries[0].isIntersecting;
          if (visible && !raf) raf = requestAnimationFrame(frame);
          if (!visible) { last = null; prev = null; }
        }).observe(el);
      }
    }

    function init() {
      if (reduced()) return;
      document.querySelectorAll('[data-image-trail]').forEach(function (el) {
        if (el._imt) return;
        el._imt = true;
        setup(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<section class="hero" data-image-trail>
  <h2>Move to explore</h2>
  <img data-trail-image src="one.jpg" alt="">
  <img data-trail-image src="two.jpg" alt="">
  <img data-trail-image src="three.jpg" alt="">
</section>