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.
Pinch, scroll or double-tap to zoom. Can you read the balloon?
How to use it
- Paste the snippet just before
</head>. - Add
data-pinch-zoom-imageto a box with a size, such as a height. - Put the image inside it. It fills the box, cropped to fit.
Options
Add any of these attributes to the same element.
data-max | The furthest it zooms in. Default 4 (four times). |
data-wheel | on (default): the mouse wheel zooms. ctrl: only with Ctrl held or a trackpad pinch, so the page scrolls freely. off: never. |
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. |
It fires zoom with detail.scale once it settles. animacious.pinchZoomImage.zoom(el, 2) zooms from code and .reset(el) zooms back out.
<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><div data-pinch-zoom-image style="height: 320px">
<img src="photo.jpg" alt="A lake at sunset">
</div>