← Cards & images

Image Crossfade Slideshow

Images fade into one another while each one slowly zooms in, every time towards a different spot, for a calm, cinematic hero or gallery. It pauses under the pointer, while it has focus and when it’s off screen.

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-image-crossfade to a container holding the images, and give it a size or an aspect-ratio.

Options

Add any of these attributes to the same element.

data-intervalHow long each image stays. Default 3500 ms.
data-fadeTime for each crossfade. Default 1400 ms.
data-zoomHow far each image zooms in. Default 1.12.
data-controls="false"No dots or pause button.

The dots jump to an image and the button pauses or plays it. Hidden images are hidden from screen readers too. With reduced motion there’s no zoom and no autoplay: the dots still switch images.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Image Crossfade Slideshow: images fade into one another while each one
    // slowly zooms in, every time towards a different spot. It pauses under
    // the pointer, while it has focus, and when off screen.
    // Usage:
    //   <div data-image-crossfade style="aspect-ratio: 16 / 9">
    //     <img src="one.jpg" alt="…">
    //     <img src="two.jpg" alt="…">
    //     <img src="three.jpg" alt="…">
    //   </div>
    // Each child is a slide. Optional: data-interval (ms each image stays,
    // default 3500), data-fade (ms for a crossfade, default 1400), data-zoom
    // (how far it zooms, default 1.12), data-controls="false" (no dots or
    // pause button).
    var DEFAULTS = { interval: 3500, fade: 1400, zoom: 1.12 };
    var ORIGINS = ['50% 50%', '30% 35%', '70% 65%', '35% 70%', '68% 32%'];
    var PAUSE = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><rect x="6" y="5" width="4" height="14" rx="1"/><rect x="14" y="5" width="4" height="14" rx="1"/></svg>';
    var PLAY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13a1 1 0 0 0 1.5.9l10-6.5a1 1 0 0 0 0-1.7l-10-6.5A1 1 0 0 0 8 5.5z"/></svg>';

    var css =
      '[data-image-crossfade]{position:relative;display:grid;overflow:hidden}' +
      '[data-image-crossfade]>:not(.ixf-bar){grid-area:1/1;width:100%;height:100%;object-fit:cover;opacity:0}' +
      '[data-image-crossfade]>:first-child{opacity:1}' +
      '.ixf-bar{position:absolute;left:0;right:0;bottom:12px;z-index:3;display:flex;justify-content:center;align-items:center;gap:6px;pointer-events:none}' +
      '.ixf-bar button{pointer-events:auto;display:grid;place-items:center;border:0;padding:0;cursor:pointer;color:#fff;background:transparent}' +
      '.ixf-bar svg{pointer-events:none}' +
      '.ixf-dot{width:22px;height:22px}' +
      '.ixf-dot::before{content:"";width:8px;height:8px;border-radius:50%;background:rgba(255,255,255,.55);box-shadow:0 1px 4px rgba(0,0,0,.3);transition:background .3s,transform .3s}' +
      '.ixf-dot[aria-current="true"]::before{background:#fff;transform:scale(1.3)}' +
      '.ixf-play{width:30px;height:30px;margin-left:6px;border-radius:50%;background:rgba(0,0,0,.35)!important}' +
      '.ixf-bar button:focus-visible{outline:2px solid #fff;outline-offset:1px;border-radius:50%}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function setup(el) {
      var d = el.dataset;
      var slides = Array.from(el.children);
      var n = slides.length;
      if (n < 2) return;
      var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      var interval = +d.interval || DEFAULTS.interval;
      var fade = +d.fade || DEFAULTS.fade;
      var zoom = +d.zoom || DEFAULTS.zoom;
      var inThumb = !!el.closest('[aria-hidden="true"]');
      var current = 0, anims = [], visible = false, hovered = false, focused = false, stopped = reduced;

      if (!inThumb) {
        el.setAttribute('role', 'region');
        el.setAttribute('aria-roledescription', 'slideshow');
      }
      slides.forEach(function (s, i) {
        anime.utils.set(s, { opacity: i ? 0 : 1, zIndex: i ? 0 : 1 });
        if (i) s.setAttribute('aria-hidden', 'true');
      });

      var dots = [], playBtn = null;
      if (d.controls !== 'false' && !inThumb) {
        var bar = document.createElement('div');
        bar.className = 'ixf-bar';
        slides.forEach(function (s, i) {
          var b = document.createElement('button');
          b.type = 'button';
          b.className = 'ixf-dot';
          b.setAttribute('aria-label', 'Show image ' + (i + 1) + ' of ' + n);
          b.addEventListener('click', function () { show(i, true); });
          bar.appendChild(b);
          dots.push(b);
        });
        if (!reduced) {
          playBtn = document.createElement('button');
          playBtn.type = 'button';
          playBtn.className = 'ixf-play';
          playBtn.addEventListener('click', function () { stopped = !stopped; sync(); });
          bar.appendChild(playBtn);
        }
        el.appendChild(bar);
      }

      // A countdown to the next slide that pauses along with everything else.
      var countdown = anime.createTimer({ duration: interval, autoplay: false, onComplete: function () { show(current + 1); } });

      function running() { return visible && !hovered && !focused && !stopped && !el.closest('[hidden]'); }

      function sync() {
        var on = running();
        anims.forEach(function (a) { if (on) a.play(); else a.pause(); });
        if (on) countdown.play(); else countdown.pause();
        // Only swap the icon when it changes: replacing it mid-click loses the click.
        if (playBtn && playBtn._ixf !== stopped) {
          playBtn._ixf = stopped;
          playBtn.innerHTML = stopped ? PLAY : PAUSE;
          playBtn.setAttribute('aria-label', stopped ? 'Play slideshow' : 'Pause slideshow');
        }
        dots.forEach(function (b, i) { b.setAttribute('aria-current', i === current ? 'true' : 'false'); });
      }

      // Slow zoom on the current slide, towards a different spot each time.
      function drift(s, i) {
        if (reduced) return;
        s.style.transformOrigin = ORIGINS[i % ORIGINS.length];
        anims.push(anime.animate(s, { scale: [1, zoom], duration: interval + fade * 2, ease: 'linear', autoplay: false }));
      }

      function show(i, user) {
        i = (i + n) % n;
        if (i === current) return;
        var from = slides[current], to = slides[i];
        current = i;
        anims.forEach(function (a) { if (a !== from._ixfZoom) a.pause(); });
        anims = from._ixfZoom ? [from._ixfZoom] : [];
        slides.forEach(function (s) { anime.utils.set(s, { zIndex: s === to ? 2 : s === from ? 1 : 0 }); });
        to.removeAttribute('aria-hidden');
        from.setAttribute('aria-hidden', 'true');
        anime.utils.set(to, { opacity: 0, scale: 1 });
        drift(to, i);
        to._ixfZoom = anims[anims.length - 1];
        anims.push(anime.animate(to, {
          opacity: 1,
          duration: reduced ? 200 : user ? Math.min(fade, 700) : fade,
          ease: 'inOut(2)',
          autoplay: false,
          onComplete: function () {
            slides.forEach(function (s) { if (s !== to) anime.utils.set(s, { opacity: 0, scale: 1 }); });
            anims = to._ixfZoom ? [to._ixfZoom] : [];
          }
        }));
        countdown.restart();
        countdown.pause();
        sync();
        // A chosen slide always shows, even while paused.
        if (!running()) anims.forEach(function (a) { if (a !== to._ixfZoom) a.play(); });
      }

      drift(slides[0], 0);
      slides[0]._ixfZoom = anims[0];

      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch') { hovered = true; sync(); } });
      el.addEventListener('pointerleave', function () { hovered = false; sync(); });
      // Focus on a slide pauses it; focus on the dots or button leaves that to the button.
      el.addEventListener('focusin', function (e) { focused = !e.target.closest('.ixf-bar'); sync(); });
      el.addEventListener('focusout', function (e) { if (!el.contains(e.relatedTarget)) { focused = false; sync(); } });
      new IntersectionObserver(function (entries) { visible = entries[0].isIntersecting; sync(); }, { threshold: 0.2 }).observe(el);
      sync();
    }

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

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-image-crossfade style="aspect-ratio: 16 / 9">
  <img src="one.jpg" alt="…">
  <img src="two.jpg" alt="…">
  <img src="three.jpg" alt="…">
</div>