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
- Paste the snippet just before
</head>. - Wrap the product photo in a box with a fixed size and add
data-hover-zoom-product-phototo the box.
Options
Add any of these attributes to the same element.
data-zoom | How far it zooms in. Default 2.2. |
data-autoplay | A 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.
<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><div class="product-photo" data-hover-zoom-product-photo>
<img src="[email protected]" alt="Trail Runner">
</div>