← E-commerce

Product Image Swap

Hover a product card and the photo gives way to a second shot, wiping in from the side while the first drifts away. Move off and it swaps back. On a phone, tap the photo to swap.

Trail Runner£89.00

How to use it

  1. Paste the snippet just before </head>.
  2. Put the two photos in a box and add data-product-image-swap to it: the first is the main photo, the second the one hover shows.
  3. To swap when anywhere on the card is hovered, put data-product-image-swap on the card and data-swap-photos on the photo box.

Options

Add any of these attributes to the same element.

data-effectslide wipes the second photo in from the side; fade cross-fades. Default slide.
data-durationHow long a swap takes. Default 550 ms.
data-dotsShow two little dots saying which photo is showing.
data-autoplayIt swaps by itself every couple of seconds while in view, until someone hovers or taps it. Handy for demos.
1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Product Image Swap: hover a product card and its photo gives way to a
    // second shot (the back, a detail, someone wearing it). The second photo
    // slides or fades in over the first with a gentle settle of the zoom, and
    // swaps back when the pointer leaves. On touch screens a tap swaps them.
    // Usage:
    //   <div data-product-image-swap>
    //     <img src="front.jpg" alt="Trail Runner">
    //     <img src="side.jpg" alt="">
    //   </div>
    // The first child is the main photo, the second the one hover shows. Put
    // data-product-image-swap on the card instead and mark the photo box with
    // data-swap-photos to swap when anywhere on the card is hovered.
    // Optional: data-effect ("slide", default, wipes the second photo in from
    // the side; "fade" cross-fades), data-duration (ms, default 550),
    // data-dots (show two little dots saying which photo is showing),
    // data-autoplay (it swaps by itself every couple of seconds while in view,
    // until someone hovers or taps it; handy for demos).
    var DEFAULTS = { duration: 550, every: 2200 };

    var css =
      '.pis-box{position:relative;display:grid;overflow:hidden;isolation:isolate}' +
      '.pis-box>:not(.pis-dots){grid-area:1/1;width:100%;height:100%;min-width:0;object-fit:cover}' +
      '.pis-box>.pis-alt{position:relative;z-index:1}' +
      '.pis-dots{position:absolute;left:50%;bottom:8px;z-index:2;display:flex;gap:5px;translate:-50% 0;pointer-events:none}' +
      '.pis-dots i{width:6px;height:6px;border-radius:50%;background:rgba(0,0,0,.18);transition:background .3s,width .3s}' +
      '.pis-dots i.pis-on{width:14px;border-radius:3px;background:rgba(0,0,0,.55)}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }

    function setup(el) {
      var box = el.querySelector('[data-swap-photos]') || el;
      var kids = Array.prototype.filter.call(box.children, function (c) { return !c.classList.contains('pis-dots'); });
      if (kids.length < 2) return;
      box.classList.add('pis-box');
      var main = kids[0], alt = kids[1];
      alt.classList.add('pis-alt');
      alt.setAttribute('aria-hidden', 'true');
      var s = el._pis = { box: box, main: main, alt: alt, on: false, timer: null, dots: null };
      if (el.dataset.dots !== undefined && el.dataset.dots !== 'false') {
        s.dots = document.createElement('span');
        s.dots.className = 'pis-dots';
        s.dots.setAttribute('aria-hidden', 'true');
        s.dots.innerHTML = '<i class="pis-on"></i><i></i>';
        box.appendChild(s.dots);
      }
      place(el, false);

      var fine = window.matchMedia('(hover: hover) and (pointer: fine)');
      el.addEventListener('pointerenter', function (e) { if (e.pointerType !== 'touch' && fine.matches) { stop(el); swap(el, true); } });
      el.addEventListener('pointerleave', function (e) { if (e.pointerType !== 'touch' && fine.matches) swap(el, false); });
      el.addEventListener('focusin', function () { swap(el, true); });
      el.addEventListener('focusout', function () { swap(el, false); });
      // No hover on touch screens: a tap on the photo swaps it instead.
      box.addEventListener('pointerup', function (e) {
        if (e.pointerType === 'mouse' && fine.matches) return;
        stop(el);
        swap(el, !s.on);
      });
    }

    function effect(el) { return el.dataset.effect === 'fade' ? 'fade' : 'slide'; }

    function place(el, on) {
      var s = el._pis;
      if (effect(el) === 'fade') {
        anime.utils.set(s.alt, { opacity: on ? 1 : 0, scale: 1, clipPath: 'inset(0 0 0 0)' });
      } else {
        anime.utils.set(s.alt, { opacity: 1, clipPath: on ? 'inset(0 0% 0 0)' : 'inset(0 0% 0 100%)' });
      }
    }

    function swap(el, on) {
      var s = el._pis;
      if (!s || s.on === on) return;
      s.on = on;
      if (s.dots) {
        s.dots.children[0].classList.toggle('pis-on', !on);
        s.dots.children[1].classList.toggle('pis-on', on);
      }
      s.main.setAttribute('aria-hidden', on);
      s.alt.setAttribute('aria-hidden', !on);
      if (reduced()) { place(el, on); return; }
      var dur = +el.dataset.duration || DEFAULTS.duration;
      anime.utils.remove([s.alt, s.main]);
      if (effect(el) === 'fade') {
        anime.animate(s.alt, { opacity: on ? 1 : 0, duration: dur, ease: 'out(2)' });
        anime.animate(on ? s.alt : s.main, { scale: [1.08, 1], duration: dur * 1.6, ease: 'out(3)' });
      } else if (on) {
        // In from the right edge, with the photo itself drifting in a touch.
        anime.animate(s.alt, { clipPath: ['inset(0 0% 0 100%)', 'inset(0 0% 0 0%)'], duration: dur, ease: 'inOut(3)' });
        anime.animate(s.alt, { x: ['12%', '0%'], scale: [1.1, 1], duration: dur * 1.4, ease: 'out(3)' });
        anime.animate(s.main, { x: ['0%', '-8%'], duration: dur, ease: 'inOut(3)' });
      } else {
        anime.animate(s.alt, { clipPath: ['inset(0 0% 0 0%)', 'inset(0 0% 0 100%)'], duration: dur, ease: 'inOut(3)' });
        anime.animate(s.alt, { x: '12%', duration: dur, ease: 'inOut(3)' });
        anime.animate(s.main, { x: ['-8%', '0%'], scale: [1.05, 1], duration: dur * 1.4, ease: 'out(3)' });
      }
    }

    function stop(el) {
      var s = el._pis;
      if (s && s.timer) { clearInterval(s.timer); s.timer = null; }
      if (s) s.stopped = true;
    }

    function autoplay(el) {
      var s = el._pis;
      new IntersectionObserver(function (entries) {
        clearInterval(s.timer);
        s.timer = null;
        if (!entries[0].isIntersecting || s.stopped) return;
        s.timer = setInterval(function () { swap(el, !s.on); }, DEFAULTS.every);
      }, { threshold: 0.3 }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-product-image-swap]').forEach(function (el) {
        if (el._pisDone) return;
        el._pisDone = true;
        setup(el);
        if (el._pis && el.dataset.autoplay !== undefined && el.dataset.autoplay !== 'false' && !reduced()) autoplay(el);
      });
    }

    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<a class="product" href="/trail-runner" data-product-image-swap>
  <div data-swap-photos>
    <img src="trail-runner.jpg" alt="Trail Runner">
    <img src="trail-runner-side.jpg" alt="">
  </div>
  <h3>Trail Runner</h3>
</a>