← Gestures

Pinch Zoom Image

Pinch an image to zoom in on the spot between your fingers, then drag to look around. Scrolling and double-tapping zoom too. Go too far in, too far out or off the edge and it stretches like rubber, then springs back when you let go.

An illustrated lake at sunset, with a house, a boat and a hot-air balloon

Pinch, scroll or double-tap to zoom. Can you read the balloon?

How to use it

  1. Paste the snippet just before </head>.
  2. Add data-pinch-zoom-image to a box with a size, such as a height.
  3. Put the image inside it. It fills the box, cropped to fit.

Options

Add any of these attributes to the same element.

data-maxThe furthest it zooms in. Default 4 (four times).
data-wheelon (default): the mouse wheel zooms. ctrl: only with Ctrl held or a trackpad pinch, so the page scrolls freely. off: never.
data-autoplayA pretend pair of fingers pinches in and out every so many ms while in view. Default 2400. A real touch pauses it.

It fires zoom with detail.scale once it settles. animacious.pinchZoomImage.zoom(el, 2) zooms from code and .reset(el) zooms back out.

1 · Snippet
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
  (function () {
    // Pinch Zoom Image: pinch (or scroll, or double-tap) to zoom into an image,
    // and drag to look around it once you are in. Push past the limits, too far
    // in, too far out or off the edge, and it stretches like rubber, then
    // springs back when you let go.
    // Usage:
    //   <div data-pinch-zoom-image style="height: 300px">
    //     <img src="photo.jpg" alt="…">
    //   </div>
    // Optional: data-max (largest zoom, default 4), data-wheel ("on", default:
    // the wheel zooms; "ctrl": only with Ctrl held or a trackpad pinch; "off"),
    // data-autoplay (a pretend pair of fingers pinches in and out every so many
    // ms while in view, default 2400; a real touch pauses it).
    // Events: "zoom" with detail.scale, once it settles.
    // Script use: animacious.pinchZoomImage.zoom(el, 2) / .reset(el)
    var DEFAULTS = { max: 4, autoplay: 2400 };

    var css =
      '.pzi{position:relative;overflow:hidden;touch-action:none;cursor:zoom-in;user-select:none;-webkit-user-select:none}' +
      '.pzi.pzi-in{cursor:grab}.pzi.pzi-held{cursor:grabbing}' +
      '.pzi-img{display:block;width:100%;height:100%;object-fit:cover;transform-origin:0 0;pointer-events:none;-webkit-user-drag:none}' +
      '.pzi-finger{position:absolute;left:0;top:0;width:30px;height:30px;margin:-15px 0 0 -15px;border-radius:50%;' +
      'background:rgba(255,255,255,.4);border:2px solid rgba(255,255,255,.9);box-shadow:0 2px 10px rgba(0,0,0,.25);pointer-events:none;opacity:0;z-index:5}';
    var style = document.createElement('style');
    style.textContent = css;
    document.head.appendChild(style);

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

    // iOS-style rubber band: gives less the further you push.
    function band(d, dim) { return (1 - 1 / (d * 0.55 / dim + 1)) * dim; }

    function setup(el) {
      var d = el.dataset;
      var img = el.firstElementChild;
      if (!img) return;
      var max = +d.max || DEFAULTS.max;
      var thumb = !!el.closest('[aria-hidden="true"]');
      el.classList.add('pzi');
      img.classList.add('pzi-img');
      img.setAttribute('draggable', 'false');

      var s = el._pzi = { s: 1, x: 0, y: 0, userAt: -1e9, anim: null };
      var pts = {};     // pointer id -> { x, y } in the box's own pixels
      var g = null;     // the gesture in progress
      var lastTap = null, settleTimer = 0;

      function size() { return { w: el.clientWidth || 1, h: el.clientHeight || 1 }; }

      function draw() {
        img.style.transform = 'translate(' + s.x + 'px,' + s.y + 'px) scale(' + s.s + ')';
        el.classList.toggle('pzi-in', s.s > 1.01);
      }

      // Allowed range for x (or y) at a given zoom.
      function range(sc, dim) {
        var lo = dim - dim * sc;
        return lo > 0 ? [lo / 2, lo / 2] : [lo, 0];
      }
      function softScale(raw) {
        if (raw > max) return max + band(raw - max, max * 0.5);
        if (raw < 1) return 1 - band(1 - raw, 0.45);
        return raw;
      }
      function softPos(v, r, dim) {
        if (v < r[0]) return r[0] - band(r[0] - v, dim);
        if (v > r[1]) return r[1] + band(v - r[1], dim);
        return v;
      }
      function hard(v, r) { return Math.max(r[0], Math.min(r[1], v)); }

      // Keep the point under (fx, fy) fixed while the zoom changes from s0 to sc.
      function place(sc, fx, fy, s0, x0, y0, soft) {
        var z = size();
        var x = fx - (fx - x0) * (sc / s0), y = fy - (fy - y0) * (sc / s0);
        s.s = sc;
        s.x = soft ? softPos(x, range(sc, z.w), z.w) : x;
        s.y = soft ? softPos(y, range(sc, z.h), z.h) : y;
        draw();
      }

      function ids() { return Object.keys(pts); }
      function mid() {
        var k = ids(), a = pts[k[0]], b = pts[k[1]];
        return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, d: Math.max(1, Math.hypot(a.x - b.x, a.y - b.y)) };
      }

      function begin() {
        if (s.anim) { s.anim.pause(); s.anim = null; }
        clearTimeout(settleTimer);
        var k = ids();
        if (k.length >= 2) {
          var m = mid();
          g = { pinch: true, m0: m, s0: s.s, x0: s.x, y0: s.y, f: m };
        } else if (k.length === 1) {
          var p = pts[k[0]];
          g = { pinch: false, p0: { x: p.x, y: p.y }, x0: s.x, y0: s.y, f: p, moved: 0, samples: [{ t: performance.now(), x: p.x, y: p.y }] };
        }
        el.classList.toggle('pzi-held', !!g);
      }

      function down(id, x, y) { pts[id] = { x: x, y: y }; begin(); }

      function move(id, x, y) {
        if (!pts[id] || !g) return;
        pts[id].x = x; pts[id].y = y;
        if (g.pinch && ids().length >= 2) {
          var m = mid();
          var sc = softScale(g.s0 * m.d / g.m0.d);
          // The spot that was under the fingers stays under them as they move.
          var z0 = size(), r = sc / g.s0;
          s.s = sc;
          s.x = softPos(m.x - (g.m0.x - g.x0) * r, range(sc, z0.w), z0.w);
          s.y = softPos(m.y - (g.m0.y - g.y0) * r, range(sc, z0.h), z0.h);
          draw();
          g.f = m;
        } else if (!g.pinch) {
          var z = size();
          var dx = x - g.p0.x, dy = y - g.p0.y;
          g.moved = Math.max(g.moved, Math.abs(dx) + Math.abs(dy));
          s.x = softPos(g.x0 + dx, range(s.s, z.w), z.w);
          s.y = softPos(g.y0 + dy, range(s.s, z.h), z.h);
          draw();
          g.f = { x: x, y: y };
          var now = performance.now();
          g.samples.push({ t: now, x: x, y: y });
          while (g.samples.length > 2 && now - g.samples[0].t > 100) g.samples.shift();
        }
      }

      function up(id) {
        if (!pts[id]) return;
        var was = g, p = pts[id];
        delete pts[id];
        if (ids().length) { begin(); return; }
        g = null;
        el.classList.remove('pzi-held');
        // A quick second tap in the same spot toggles the zoom.
        if (was && !was.pinch && was.moved < 8) {
          var now = performance.now();
          if (lastTap && now - lastTap.t < 320 && Math.hypot(p.x - lastTap.x, p.y - lastTap.y) < 30) {
            lastTap = null;
            zoomTo(s.s > 1.05 ? 1 : Math.min(2.5, max), p.x, p.y);
            return;
          }
          lastTap = { t: now, x: p.x, y: p.y };
        }
        var vx = 0, vy = 0;
        if (was && !was.pinch) {
          var sm = was.samples, a = sm[0], b = sm[sm.length - 1], dt = (b.t - a.t) / 1000;
          if (dt > 0 && performance.now() - b.t < 80) { vx = (b.x - a.x) / dt; vy = (b.y - a.y) / dt; }
        }
        settle(was ? was.f : null, vx, vy);
      }

      function tween(to, ms, ease) {
        if (s.anim) s.anim.pause();
        var o = { s: s.s, x: s.x, y: s.y };
        s.anim = anime.animate(o, {
          s: to.s, x: to.x, y: to.y,
          duration: reduced() ? 0 : ms,
          ease: ease,
          onUpdate: function () { s.s = o.s; s.x = o.x; s.y = o.y; draw(); },
          onComplete: function () {
            s.anim = null;
            el.dispatchEvent(new CustomEvent('zoom', { detail: { scale: to.s } }));
          },
        });
      }

      // Spring back inside the limits (gliding on a little if it was flicked).
      function settle(f, vx, vy) {
        var z = size();
        f = f || { x: z.w / 2, y: z.h / 2 };
        var ts = Math.max(1, Math.min(max, s.s));
        var x = f.x - (f.x - s.x) * (ts / s.s), y = f.y - (f.y - s.y) * (ts / s.s);
        var out = ts !== s.s || x !== hard(x, range(ts, z.w)) || y !== hard(y, range(ts, z.h));
        x = hard(x + (vx || 0) * 0.22, range(ts, z.w));
        y = hard(y + (vy || 0) * 0.22, range(ts, z.h));
        if (Math.abs(x - s.x) + Math.abs(y - s.y) + Math.abs(ts - s.s) * 100 < 0.5) return;
        tween({ s: ts, x: x, y: y }, out ? 750 : 600, out ? 'outElastic(1, .85)' : 'out(3)');
      }

      function zoomTo(ts, fx, fy) {
        var z = size();
        ts = Math.max(1, Math.min(max, ts));
        if (fx === undefined) { fx = z.w / 2; fy = z.h / 2; }
        var x = hard(fx - (fx - s.x) * (ts / s.s), range(ts, z.w));
        var y = hard(fy - (fy - s.y) * (ts / s.s), range(ts, z.h));
        tween({ s: ts, x: x, y: y }, 520, 'out(4)');
      }
      s.zoomTo = zoomTo;

      function local(e) {
        var r = el.getBoundingClientRect();
        return { x: e.clientX - r.left, y: e.clientY - r.top };
      }

      if (!thumb) {
        el.addEventListener('pointerdown', function (e) {
          if (e.button) return;
          s.userAt = performance.now();
          hideFingers();
          var p = local(e);
          el.setPointerCapture(e.pointerId);
          down('u' + e.pointerId, p.x, p.y);
        });
        el.addEventListener('pointermove', function (e) {
          if (!pts['u' + e.pointerId]) return;
          s.userAt = performance.now();
          var p = local(e);
          move('u' + e.pointerId, p.x, p.y);
        });
        el.addEventListener('pointerup', function (e) { up('u' + e.pointerId); });
        el.addEventListener('pointercancel', function (e) { up('u' + e.pointerId); });

        var wheelRaw = 1;
        el.addEventListener('wheel', function (e) {
          if (d.wheel === 'off' || (d.wheel === 'ctrl' && !e.ctrlKey)) return;
          var dy = e.deltaY * (e.deltaMode === 1 ? 16 : 1);
          var f = Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0018));
          if (s.s <= 1.001 && f < 1 && !e.ctrlKey) return;   // let the page scroll on
          e.preventDefault();
          s.userAt = performance.now();
          if (s.anim) { s.anim.pause(); s.anim = null; }
          if (!settleTimer) wheelRaw = s.s;
          clearTimeout(settleTimer);
          var p = local(e), s0 = s.s;
          wheelRaw *= f;
          place(softScale(wheelRaw), p.x, p.y, s0, s.x, s.y, true);
          settleTimer = setTimeout(function () { settleTimer = 0; settle(p); }, 180);
        }, { passive: false });
      }
      draw();
      new ResizeObserver(function () { if (!g && !s.anim) settle(null); }).observe(el);

      if (d.autoplay === undefined || d.autoplay === 'false' || reduced()) return;
      var every = +d.autoplay || DEFAULTS.autoplay;
      var fa = finger(), fb = finger();
      function finger() {
        var f = document.createElement('span');
        f.className = 'pzi-finger';
        f.setAttribute('aria-hidden', 'true');
        el.appendChild(f);
        return f;
      }
      function hideFingers() { anime.animate([fa, fb], { opacity: 0, duration: 200 }); }
      var visible = false, timer = 0, step = 0;
      new IntersectionObserver(function (entries) {
        visible = entries[0].isIntersecting;
        clearTimeout(timer);
        if (visible) timer = setTimeout(play, 700);
      }).observe(el);

      function busy() { return performance.now() - s.userAt < 6000 || g; }

      // Move pretend fingers from one pair of spots to another, as a real gesture.
      function gesture(from, to, ms, ease, done) {
        var both = from.length > 2;
        anime.utils.set(fa, { x: from[0], y: from[1], scale: 1.3 });
        if (both) anime.utils.set(fb, { x: from[2], y: from[3], scale: 1.3 });
        anime.animate(both ? [fa, fb] : fa, { opacity: 1, scale: 1, duration: 200, ease: 'out(2)' });
        timer = setTimeout(function () {
          if (busy()) { hideFingers(); return done(true); }
          down('a', from[0], from[1]);
          if (both) down('b', from[2], from[3]);
          var o = { t: 0 };
          anime.animate(o, {
            t: 1, duration: ms, ease: ease,
            onUpdate: function () {
              var ax = from[0] + (to[0] - from[0]) * o.t, ay = from[1] + (to[1] - from[1]) * o.t;
              move('a', ax, ay);
              anime.utils.set(fa, { x: ax, y: ay });
              if (both) {
                var bx = from[2] + (to[2] - from[2]) * o.t, by = from[3] + (to[3] - from[3]) * o.t;
                move('b', bx, by);
                anime.utils.set(fb, { x: bx, y: by });
              }
            },
            onComplete: function () {
              timer = setTimeout(function () {
                up('a'); up('b');
                hideFingers();
                done(false);
              }, 90);
            },
          });
        }, 220);
      }

      function pair(cx, cy, half, ang) {
        var dx = Math.cos(ang) * half, dy = Math.sin(ang) * half;
        return [cx - dx, cy - dy, cx + dx, cy + dy];
      }

      function play() {
        if (!visible) return;
        if (busy()) { timer = setTimeout(play, 900); return; }
        var z = size();
        var cx = z.w * (0.35 + Math.random() * 0.3), cy = z.h * (0.35 + Math.random() * 0.3);
        var ang = -0.5 - Math.random() * 0.5, small = Math.min(z.w, z.h);
        var near = small * 0.14, far = small * 0.36;
        if (step++ % 2 === 0) {
          // Pinch out, drag around a little, then pinch back in too far and let it spring.
          gesture(pair(cx, cy, near, ang), pair(cx, cy, far, ang), 900, 'inOut(2)', function (stop) {
            if (stop) return next(900);
            timer = setTimeout(function () {
              var sx = z.w * 0.5, sy = z.h * 0.5;
              gesture([sx, sy], [sx - z.w * 0.18, sy - z.h * 0.12], 650, 'inOut(2)', function (stop2) {
                if (stop2) return next(900);
                timer = setTimeout(function () {
                  gesture(pair(z.w / 2, z.h / 2, far * 1.05, ang), pair(z.w / 2, z.h / 2, near * 0.75, ang), 900, 'inOut(2)', function () {
                    next(every);
                  });
                }, 700);
              });
            }, 600);
          });
        } else {
          // A double-tap to zoom in, then another to zoom out.
          tap(cx, cy, function () {
            timer = setTimeout(function () { tap(z.w / 2, z.h / 2, function () { next(every); }); }, 1300);
          });
        }
      }
      function next(ms) { timer = setTimeout(play, ms); }

      function tap(x, y, done) {
        anime.utils.set(fa, { x: x, y: y, scale: 1.3 });
        var n = 0;
        (function one() {
          if (busy()) { hideFingers(); return done(); }
          anime.animate(fa, { opacity: [0, 1], scale: [1.35, 0.9], duration: 160, ease: 'out(2)' });
          down('a', x, y);
          timer = setTimeout(function () {
            up('a');
            anime.animate(fa, { opacity: 0, scale: 1.2, duration: 160, ease: 'out(2)' });
            if (++n < 2) timer = setTimeout(one, 120);
            else done();
          }, 90);
        })();
      }
    }

    function init() {
      document.querySelectorAll('[data-pinch-zoom-image]').forEach(function (el) {
        if (el._pzi) return;
        setup(el);
      });
    }

    window.animacious = window.animacious || {};
    window.animacious.pinchZoomImage = {
      zoom: function (el, scale) { if (el._pzi) el._pzi.zoomTo(scale); },
      reset: function (el) { if (el._pzi) el._pzi.zoomTo(1); },
    };
    if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
    else init();
  })();
</script>
2 · Your HTML
<div data-pinch-zoom-image style="height: 320px">
  <img src="photo.jpg" alt="A lake at sunset">
</div>