Infinite Zoom
Zoom into a picture for ever. The image holds a small framed copy of itself, which holds a smaller copy, and so on. The view glides steadily into the copy until it fills the box and becomes the picture again, so the zoom never ends and never jumps.
How to use it
- Paste the snippet just before
</head>. - Wrap an image in an element with
data-infinite-zoomand give the wrapper a size. The image fills it, cropped to cover. - Use a large image, so it stays sharp as each copy grows to full size.
Options
Add any of these attributes to the same element.
data-duration | Time for each copy to grow to full size. Default 4000 ms. |
data-scale | Size of the copy against the whole picture. Default 0.36. |
data-x | Where the copy sits across the picture, from 0 to 1. Default 0.5. |
data-y | Where the copy sits down the picture, from 0 to 1. Default 0.5. |
data-frame | Colour of the frame round each copy. Default #fff; none for no frame. |
data-reverse | Zoom out instead of in. |
With reduced motion it shows the picture with its copies, standing still.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Infinite Zoom: the image holds a smaller, framed copy of itself, which
// holds a smaller copy, and so on. The view zooms steadily into the copy,
// which grows until it fills the box and becomes the picture again, so the
// zoom goes on for ever without a jump.
// Usage: <figure data-infinite-zoom><img src="photo.jpg" alt="…"></figure>
// The image (an <img>, or an inline <svg>) stays in the page for screen
// readers; give the wrapper a size.
// Optional: data-duration (ms for each copy to grow to full size, default
// 4000), data-scale (size of the copy against the picture, default 0.36),
// data-x and data-y (where the copy sits, from 0 to 1, default 0.5 each),
// data-frame (the frame's colour, default "#fff"; "none" for no frame),
// data-reverse (zoom out instead of in).
var DEFAULTS = { duration: 4000, scale: 0.36, x: 0.5, y: 0.5, frame: '#fff' };
var css =
'.izm{position:relative;overflow:hidden;background:#111}' +
'.izm-src{opacity:0}' +
'.izm-canvas{position:absolute;inset:0;width:100%;height:100%;pointer-events:none}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
// Turn the source (<img> or inline <svg>) into something drawImage can use.
function loadSource(node, done) {
if (node.tagName.toLowerCase() === 'svg') {
var vb = node.viewBox && node.viewBox.baseVal, r = node.getBoundingClientRect();
var vw = (vb && vb.width) || r.width || 300, vh = (vb && vb.height) || r.height || 150;
var k = 1600 / Math.max(vw, vh);
var clone = node.cloneNode(true);
clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
clone.setAttribute('width', Math.round(vw * k));
clone.setAttribute('height', Math.round(vh * k));
var im = new Image();
im.onload = function () { done(im, im.width, im.height); };
im.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(new XMLSerializer().serializeToString(clone));
} else if (node.complete && node.naturalWidth) {
done(node, node.naturalWidth, node.naturalHeight);
} else {
node.addEventListener('load', function () { done(node, node.naturalWidth, node.naturalHeight); });
}
}
function setup(el, img, iw, ih) {
var d = el.dataset;
var duration = +d.duration || DEFAULTS.duration;
var r = Math.min(0.8, Math.max(0.12, +d.scale || DEFAULTS.scale));
var px = d.x !== undefined ? +d.x : DEFAULTS.x;
var py = d.y !== undefined ? +d.y : DEFAULTS.y;
var frame = d.frame || DEFAULTS.frame;
var reverse = d.reverse !== undefined && d.reverse !== 'false';
var canvas = document.createElement('canvas');
canvas.className = 'izm-canvas';
canvas.setAttribute('aria-hidden', 'true');
el.appendChild(canvas);
var ctx = canvas.getContext('2d');
// The picture, cropped once to the box's shape, so every copy matches it exactly.
var pic = document.createElement('canvas');
var w = 0, h = 0, dpr = 1;
function size() {
dpr = Math.min(2, window.devicePixelRatio || 1);
w = el.clientWidth;
h = el.clientHeight;
if (!w || !h) return;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
// Big enough to stay sharp while it is enlarged up to 1/scale.
var k = Math.min(2.2, 1600 / Math.max(w, h)) * dpr;
pic.width = Math.round(w * k);
pic.height = Math.round(h * k);
var p = pic.getContext('2d');
var s = Math.max(pic.width / iw, pic.height / ih);
p.drawImage(img, (pic.width - iw * s) / 2, (pic.height - ih * s) / 2, iw * s, ih * s);
}
// z runs from 1 (the picture fills the box) up to 1/r (its copy fills the box).
function draw(z) {
if (!w || !h) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Every copy shares one fixed point, so zooming about it lands each copy exactly where its parent was.
var fx = px * w, fy = py * h;
var s = z;
for (var i = 0; i < 12; i++) {
var bw = w * s, bh = h * s;
if (bw < 2) break;
var x = fx - px * bw, y = fy - py * bh;
if (i > 0 && frame !== 'none') {
// The frame: a mount around the copy and a soft shadow under it.
var m = Math.max(1, bw * 0.035);
ctx.save();
ctx.shadowColor = 'rgba(0,0,0,.45)';
ctx.shadowBlur = Math.max(2, bw * 0.06);
ctx.shadowOffsetY = Math.max(1, bw * 0.015);
ctx.fillStyle = frame;
ctx.fillRect(x - m, y - m, bw + m * 2, bh + m * 2);
ctx.restore();
}
ctx.drawImage(pic, x, y, bw, bh);
s *= r;
}
}
size();
draw(1);
if (window.ResizeObserver) {
new ResizeObserver(function () {
if (el.clientWidth === w && el.clientHeight === h) return;
size();
draw(1);
}).observe(el);
}
if (reduced()) return;
var p = 0, last = 0;
var timer = anime.createTimer({
loop: true, duration: 1000, autoplay: false,
onUpdate: function () {
var now = performance.now();
var dt = last ? Math.min(50, now - last) : 16;
last = now;
p = (p + dt / duration) % 1;
// Exponential, so the zoom feels the same speed all the way through.
var z = Math.pow(1 / r, reverse ? 1 - p : p);
draw(z);
}
});
new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting) { last = 0; timer.play(); }
else timer.pause();
}, { threshold: 0.1 }).observe(el);
}
function init() {
document.querySelectorAll('[data-infinite-zoom]').forEach(function (el) {
if (el._izm) return;
var src = el.querySelector('img, svg');
if (!src) return;
el._izm = true;
el.classList.add('izm');
src.classList.add('izm-src');
loadSource(src, function (img, iw, ih) { setup(el, img, iw, ih); });
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><figure data-infinite-zoom style="height:400px">
<img src="photo.jpg" alt="A mountain lake">
</figure>