Magnifier Lens
A round glass lens pops up under the pointer and follows it across the picture, showing what’s beneath it at twice the size. Move away and it shrinks out of sight.
How to use it
- Paste the snippet just before
</head>. - Add
data-magnifier-lensto the frame around a picture.
Options
Add any of these attributes to the same element.
data-zoom | How much the lens magnifies. Default 2. |
data-size | Width of the lens, in px. Default 150, and never more than about half the frame. |
data-src | A larger version of the photo for the lens to show, so the close-up stays sharp. |
data-autoplay | A pretend pointer glides from spot to spot while a real one isn’t over it. Handy for demos and touch screens. |
The lens shows a copy of what’s in the frame, so it works best on a single picture or one block of content. It hides the cursor while it’s showing. On touch screens it jumps to your finger.
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/bundles/anime.umd.min.js"></script>
<script>
(function () {
// Magnifier Lens: a round lens follows the pointer over a picture and shows
// what's beneath it magnified, popping in when the pointer arrives and
// shrinking away when it leaves.
// Usage: <figure data-magnifier-lens><img src="…" alt="…"></figure>
// Works best on a picture, or a frame holding one block of content.
// Optional: data-zoom (how much it magnifies, default 2), data-size (lens
// diameter, px, default 150), data-autoplay (a pretend pointer glides from
// spot to spot while a real one isn't over it; handy for demos and touch
// screens).
// For a sharper close-up of a photo, put a larger version on the frame as
// data-src and the lens shows that instead.
var DEFAULTS = { zoom: 2, size: 150 };
var css =
'.mgl{position:relative;overflow:hidden}' +
'@media (hover:hover) and (pointer:fine){.mgl.mgl-on{cursor:none}}' +
'.mgl-lens{position:absolute;left:0;top:0;z-index:3;overflow:hidden;border-radius:50%;pointer-events:none;' +
'border:3px solid #fff;box-shadow:0 10px 30px rgba(20,10,40,.35),inset 0 0 0 1px rgba(0,0,0,.08);background:#fff;opacity:0;will-change:transform}' +
'.mgl-view{position:absolute;left:0;top:0;transform-origin:0 0;box-sizing:border-box}' +
'.mgl-view>*{max-width:none}' +
'.mgl-shine{position:absolute;inset:0;border-radius:50%;background:radial-gradient(circle at 32% 26%,rgba(255,255,255,.35),rgba(255,255,255,0) 40%)}';
var style = document.createElement('style');
style.textContent = css;
document.head.appendChild(style);
function reduced() { return window.matchMedia('(prefers-reduced-motion: reduce)').matches; }
function num(v, def) { return v !== undefined && v !== '' && !isNaN(+v) ? +v : def; }
function setup(el) {
var d = el.dataset;
var zoom = Math.max(1, num(d.zoom, DEFAULTS.zoom));
var size = num(d.size, DEFAULTS.size);
el.classList.add('mgl');
var lens = document.createElement('div');
lens.className = 'mgl-lens';
lens.setAttribute('aria-hidden', 'true');
var view = document.createElement('div');
view.className = 'mgl-view';
[].slice.call(el.children).forEach(function (c) {
var copy = c.cloneNode(true);
copy.removeAttribute('id');
if (copy.tagName === 'IMG' && d.src) copy.src = d.src;
view.appendChild(copy);
});
var shine = document.createElement('span');
shine.className = 'mgl-shine';
lens.appendChild(view);
lens.appendChild(shine);
el.appendChild(lens);
var w = 0, h = 0, S = size;
function measure() {
w = el.clientWidth; h = el.clientHeight;
var cs = getComputedStyle(el);
view.style.width = w + 'px';
view.style.height = h + 'px';
view.style.padding = cs.padding;
view.style.display = cs.display === 'inline' ? 'block' : cs.display;
// Never bigger than about half the frame, so it fits on a phone too.
S = Math.round(Math.min(size, Math.min(w, h) * 0.62));
lens.style.width = lens.style.height = S + 'px';
}
measure();
new ResizeObserver(measure).observe(el);
var s = { x: 0, y: 0, tx: 0, ty: 0, a: 0 };
var hover = false, hold = 0, visible = false, raf = 0, shown = false;
var auto = d.autoplay !== undefined && d.autoplay !== 'false' && !reduced();
var glide = null, nextGlide = 0;
function draw() {
var b = 3; // lens border
var inner = S - b * 2;
lens.style.transform = 'translate(' + (s.x - S / 2).toFixed(1) + 'px,' + (s.y - S / 2).toFixed(1) + 'px) scale(' + (0.4 + 0.6 * s.a).toFixed(3) + ')';
lens.style.opacity = Math.min(1, s.a * 1.4).toFixed(3);
view.style.transform = 'translate(' + (inner / 2 - s.x * zoom).toFixed(1) + 'px,' + (inner / 2 - s.y * zoom).toFixed(1) + 'px) scale(' + zoom + ')';
}
function aim(cx, cy, jump) {
var r = el.getBoundingClientRect();
s.tx = Math.max(0, Math.min(w, cx - r.left));
s.ty = Math.max(0, Math.min(h, cy - r.top));
if (jump || s.a < 0.05) { s.x = s.tx; s.y = s.ty; }
}
// Pretend pointer: glide to a spot, linger, glide on.
function pretend(now) {
if (glide && now < glide.end) {
var p = (now - glide.start) / (glide.end - glide.start);
var e = p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;
s.tx = glide.x0 + (glide.x1 - glide.x0) * e;
s.ty = glide.y0 + (glide.y1 - glide.y0) * e;
return;
}
if (glide) { s.tx = glide.x1; s.ty = glide.y1; glide = null; nextGlide = now + 700; }
if (now >= nextGlide) {
var x1 = w * (0.18 + Math.random() * 0.64), y1 = h * (0.22 + Math.random() * 0.56);
if (s.a < 0.05) { s.x = s.tx = w * 0.5; s.y = s.ty = h * 0.5; }
glide = { x0: s.tx, y0: s.ty, x1: x1, y1: y1, start: now, end: now + 1300 };
}
}
function frame(now) {
raf = 0;
if (!visible) return;
var touchHeld = Date.now() < hold;
var fake = auto && !hover && !touchHeld;
if (fake) pretend(now); else glide = null;
var on = hover || touchHeld || fake;
var k = reduced() ? 1 : 0.3;
s.x += (s.tx - s.x) * k;
s.y += (s.ty - s.y) * k;
s.a += ((on ? 1 : 0) - s.a) * (reduced() ? 1 : 0.18);
if (on !== shown) { shown = on; el.classList.toggle('mgl-on', on); }
draw();
if (on || s.a > 0.01) raf = requestAnimationFrame(frame);
else { s.a = 0; draw(); }
}
function wake() { if (!raf && visible) raf = requestAnimationFrame(frame); }
el.addEventListener('pointerenter', function (e) {
if (e.pointerType === 'touch') return;
hover = true;
aim(e.clientX, e.clientY);
wake();
});
el.addEventListener('pointermove', function (e) {
if (e.pointerType === 'touch') hold = Date.now() + 4000;
else hover = true;
aim(e.clientX, e.clientY);
wake();
});
el.addEventListener('pointerleave', function (e) {
if (e.pointerType === 'touch') return;
hover = false;
wake();
});
// Touch: the lens jumps to the finger and stays a few seconds.
el.addEventListener('pointerdown', function (e) {
if (e.pointerType !== 'touch') return;
hold = Date.now() + 4000;
aim(e.clientX, e.clientY, true);
wake();
});
new IntersectionObserver(function (entries) {
visible = entries[0].isIntersecting;
if (visible) wake();
}).observe(el);
draw();
}
function init() {
document.querySelectorAll('[data-magnifier-lens]').forEach(function (el) {
if (el._mgl) return;
el._mgl = true;
setup(el);
});
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
</script><figure data-magnifier-lens>
<img src="map.jpg" alt="Map of the old town">
</figure>
<!-- stronger, with a sharper close-up: -->
<figure data-magnifier-lens data-zoom="3" data-src="map-large.jpg">
<img src="map.jpg" alt="Map of the old town">
</figure>