← E-commerce

Hover Zoom Product Photo

Move the pointer over a product photo and it zooms in, panning smoothly so the spot under the pointer is the one you see up close. Move away and it eases back out. On a phone, tap to zoom, drag to look around and tap again to zoom out.

How to use it

  1. Paste the snippet just before </head>.
  2. Wrap the product photo in a box with a fixed size and add data-hover-zoom-product-photo to the box.

Options

Add any of these attributes to the same element.

data-zoomHow far it zooms in. Default 2.2.
data-autoplayA pretend pointer wanders over the photo while nobody is using it. Handy for demos.

Use a photo with more pixels than the box shows (say twice the size), so it stays sharp when zoomed.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Hover Zoom Product Photo: move the pointer over a product photo and it
    // zooms in, panning smoothly so the bit under the pointer is the bit you
    // see up close. Move away and it eases back out. On touch screens, tap to
    // zoom in, drag to look around, and tap again to zoom out.
    // Usage: <div data-hover-zoom-product-photo><img src="shoe.jpg" alt="Trail Runner"></div>
    // The first child is the photo; the element keeps its size and clips it.
    // Optional: data-zoom (how far it zooms, default 2.2), data-autoplay (a
    // pretend pointer wanders over the photo while nobody is using it; handy
    // for demos).
    var DEFAULTS = { zoom: 2.2 };

    var css =
      '.hzp{position:relative;overflow:hidden;cursor:zoom-in;-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent}' +
      '.hzp.hzp-on{touch-action:none}' +
      '.hzp>.hzp-photo{width:100%;height:100%;transform-origin:0 0;will-change:transform;pointer-events:none}' +
      '.hzp-hint{position:absolute;right:10px;bottom:10px;display:grid;place-items:center;width:30px;height:30px;border-radius:50%;' +
      'background:rgba(255,255,255,.9);color:#1a1a2e;box-shadow:0 2px 8px rgba(0,0,0,.12);pointer-events:none;transition:opacity .3s,transform .3s}' +
      '.hzp-hint svg{width:16px;height:16px}' +
      '.hzp-on .hzp-hint{opacity:0;transform:scale(.6)}' +
      '.hzp-ptr{position:absolute;left:0;top:0;width:34px;height:34px;margin:-17px 0 0 -17px;border-radius:50%;border:2px solid #fff;' +
      'box-shadow:0 0 0 1px rgba(0,0,0,.15),0 4px 12px rgba(0,0,0,.2);background:rgba(255,255,255,.15);pointer-events:none;opacity:0}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

    var GLASS = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><circle cx="10.5" cy="10.5" r="6"/><path d="M15 15l5 5M10.5 8v5M8 10.5h5"/></svg>';

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

    function setup(el) {
      var photo = el.firstElementChild;
      if (!photo) return;
      el.classList.add('hzp');
      photo.classList.add('hzp-photo');
      var hint = document.createElement('span');
      hint.className = 'hzp-hint';
      hint.setAttribute('aria-hidden', 'true');
      hint.innerHTML = GLASS;
      el.appendChild(hint);
      var zoom = +el.dataset.zoom || DEFAULTS.zoom;
      var anim = anime.createAnimatable(photo, { x: 380, y: 380, scale: 480, ease: 'out(3)' });
      var s = el._hzp = { photo: photo, anim: anim, zoom: zoom, on: false, user: false };
      if (reduced()) { s.anim = null; }

      // Point the zoom at a spot given as fractions (0..1) of the box.
      s.aim = function (fx, fy) {
        fx = Math.max(0, Math.min(1, fx));
        fy = Math.max(0, Math.min(1, fy));
        var w = el.clientWidth, h = el.clientHeight, z = s.on ? zoom : 1;
        var x = -(z - 1) * fx * w, y = -(z - 1) * fy * h;
        if (s.anim) { s.anim.x(x); s.anim.y(y); s.anim.scale(z); }
        else anime.utils.set(photo, { x: x, y: y, scale: z });
        s.fx = fx; s.fy = fy;
      };
      s.set = function (on, fx, fy) {
        s.on = on;
        el.classList.toggle('hzp-on', on);
        s.aim(fx === undefined ? s.fx || 0.5 : fx, fy === undefined ? s.fy || 0.5 : fy);
      };

      function frac(e) {
        var r = el.getBoundingClientRect();
        return [(e.clientX - r.left) / r.width, (e.clientY - r.top) / r.height];
      }
      var fine = window.matchMedia('(hover: hover) and (pointer: fine)');
      el.addEventListener('pointerenter', function (e) {
        if (e.pointerType === 'touch' || !fine.matches) return;
        s.user = true;
        var f = frac(e);
        s.set(true, f[0], f[1]);
      });
      el.addEventListener('pointermove', function (e) {
        if (e.pointerType !== 'touch' && !fine.matches) return;
        if (e.pointerType === 'touch' && !s.on) return;
        if (e.pointerType !== 'touch') { s.user = true; if (!s.on) s.set(true); }
        var f = frac(e);
        s.aim(f[0], f[1]);
      });
      el.addEventListener('pointerleave', function (e) {
        if (e.pointerType === 'touch' || !fine.matches) return;
        s.set(false);
        s.user = false;
      });
      // Touch: a tap toggles the zoom; while zoomed, dragging pans.
      var down = null;
      el.addEventListener('pointerdown', function (e) {
        if (e.pointerType === 'mouse' && fine.matches) return;
        down = { x: e.clientX, y: e.clientY };
      });
      el.addEventListener('pointerup', function (e) {
        if (!down) return;
        var moved = Math.abs(e.clientX - down.x) + Math.abs(e.clientY - down.y);
        down = null;
        if (moved > 10) return;
        s.user = true;
        var f = frac(e);
        s.set(!s.on, f[0], f[1]);
        if (!s.on) s.user = false;
      });
    }

    function autoplay(el) {
      var s = el._hzp;
      var ptr = document.createElement('span');
      ptr.className = 'hzp-ptr';
      ptr.setAttribute('aria-hidden', 'true');
      el.appendChild(ptr);
      var raf = 0, t0 = 0, shown = false;
      var CYCLE = 6200; // ms: wander in, look around, back out, rest
      function frame(now) {
        raf = requestAnimationFrame(frame);
        if (!t0) t0 = now;
        if (s.user) {
          if (shown) { anime.animate(ptr, { opacity: 0, duration: 200 }); shown = false; }
          t0 = now;
          return;
        }
        var t = (now - t0) % CYCLE;
        var a = t / 1000;
        // A lazy figure-of-eight over the photo.
        var fx = 0.5 + 0.36 * Math.sin(a * 1.1);
        var fy = 0.48 + 0.16 * Math.sin(a * 2.2 + 0.6);
        var inside = t > 500 && t < 4800;
        if (inside !== s.on) s.set(inside, fx, fy);
        else if (inside) s.aim(fx, fy);
        var vis = t > 200 && t < 5200;
        if (vis !== shown) { shown = vis; anime.animate(ptr, { opacity: vis ? 1 : 0, scale: vis ? [0.6, 1] : 0.6, duration: 260, ease: 'out(2)' }); }
        ptr.style.left = fx * el.clientWidth + 'px';
        ptr.style.top = fy * el.clientHeight + 'px';
      }
      new IntersectionObserver(function (entries) {
        cancelAnimationFrame(raf);
        if (entries[0].isIntersecting) raf = requestAnimationFrame(frame);
      }).observe(el);
    }

    function init() {
      document.querySelectorAll('[data-hover-zoom-product-photo]').forEach(function (el) {
        if (el._hzpDone) return;
        el._hzpDone = true;
        setup(el);
        if (el._hzp && 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
<div class="product-photo" data-hover-zoom-product-photo>
  <img src="[email protected]" alt="Trail Runner">
</div>