9 Image Zoom Viewer Interactions — No Photo Files
An image zoom viewer is the layer between a picture and the person studying it: it magnifies, turns, or lights up one part on demand.
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Magnifier lens that follows the pointer
- 02 Wheel to zoom, drag to pan
- 03 Minimap that shows where you are
- 04 Zoom control bar with snapped steps
- 05 Rotate and flip toolbar
- 06 Lightbox that grows out of its thumbnail
- 07 360-degree view you drag around
- 08 Spotlight that lights up under the pointer
- 09 Caption pins stuck onto the picture
The order follows how someone actually inspects a picture rather than how popular each pattern is. You start by magnifying one spot without committing to anything (01), then pull the whole frame in and drag it around (02). Once you are zoomed in, you lose your place, so a minimap arrives (03), and when the exact ratio matters, a stepped control joins it (04). A sideways scan gets turned upright (05), one thumbnail out of several opens full size (06), a product spins so you can see its back (07), and an explanation either lights up one spot (08) or plants a label on it (09). Each one is under sixty lines of vanilla JavaScript, and the artwork inside is nothing but linear-gradient, radial-gradient, and their repeating variants.
01Magnifier lens that follows the pointer
The round lens holds a second copy of the same background, drawn at 2.4 times the size. Multiply the pointer coordinate by that factor, subtract the lens radius, and the result is the background-position that keeps both layers locked together. The coordinate is clamped between the radius and the picture edge so the lens never slips past the edge of the artwork.
var MG_ZOOM = 2.4;
function mgPaint() {
var box = mgPhoto.getBoundingClientRect();
var half = mgLens.offsetWidth / 2;
mgX = Math.min(Math.max(mgX, half), box.width - half);
mgY = Math.min(Math.max(mgY, half), box.height - half);
mgLens.style.transform = 'translate(' + (mgX - half) + 'px,' + (mgY - half) + 'px)';
mgLens.style.backgroundPosition = -(mgX * MG_ZOOM - half) + 'px ' + -(mgY * MG_ZOOM - half) + 'px';
}
mgPhoto.addEventListener('pointermove', function (e) {
var box = mgPhoto.getBoundingClientRect();
mgX = e.clientX - box.left;
mgY = e.clientY - box.top;
mgPaint();
});
02Wheel to zoom, drag to pan
The wheel runs the scale from 1x to 3x, and dragging slides the map under your finger. How far it may slide is (width × (scale − 1)) ÷ 2 ÷ scale, worked out before the transform is written. Without that clamp, one firm drag pushes the artwork off its own frame and exposes the empty backing.
function zpPaint() {
var box = zpView.getBoundingClientRect();
var maxX = box.width * (zpScale - 1) / 2 / zpScale;
var maxY = box.height * (zpScale - 1) / 2 / zpScale;
zpX = Math.min(Math.max(zpX, -maxX), maxX);
zpY = Math.min(Math.max(zpY, -maxY), maxY);
zpLayer.style.transform = 'scale(' + zpScale + ') translate(' + zpX + 'px,' + zpY + 'px)';
}
zpView.addEventListener('wheel', function (e) {
e.preventDefault();
zpScale = Math.min(Math.max(zpScale - e.deltaY / 400, 1), 3);
zpPaint();
}, { passive: false });
03Minimap that shows where you are
The tile in the corner is the whole blueprint shrunk down, and the white box inside it marks the slice currently on screen. At 2.4x the box is 1 ÷ 2.4 of the minimap, or 41.67 percent, and the remaining 58.33 percent is the distance it can travel. Both the big view and the box derive from the same pan values, so they cannot drift apart.
var MM_SCALE = 2.4;
function mmPaint() {
var view = mmView.getBoundingClientRect();
var map = mmMap.getBoundingClientRect();
var rest = (1 - 1 / MM_SCALE) / 2;
var tx = mmPx * view.width * (MM_SCALE - 1) / 2 / MM_SCALE;
var ty = mmPy * view.height * (MM_SCALE - 1) / 2 / MM_SCALE;
mmLayer.style.transform = 'scale(' + MM_SCALE + ') translate(' + tx + 'px,' + ty + 'px)';
mmBox.style.transform = 'translate(' + rest * map.width * (1 - mmPx) + 'px,'
+ rest * map.height * (1 - mmPy) + 'px)';
}
04Zoom control bar with snapped steps
The scale only ever lands on the four values in the array. At either end, the matching button turns itself off with disabled, so nobody keeps pressing a control that has nothing left to do. The reading sits in an element with role="status", which hands the new ratio to a screen reader once per change.
var ZL_STOPS = [1, 1.5, 2, 3];
function zlPaint() {
zlLayer.style.transform = 'scale(' + ZL_STOPS[zlStep] + ')';
zlPct.textContent = Math.round(ZL_STOPS[zlStep] * 100) + '%';
document.getElementById('zl-out').disabled = zlStep === 0;
document.getElementById('zl-in').disabled = zlStep === ZL_STOPS.length - 1;
}
document.getElementById('zl-in').addEventListener('click', function () {
zlStep = Math.min(zlStep + 1, ZL_STOPS.length - 1);
zlPaint();
});
05Rotate and flip toolbar
The angle only ever grows by 90; it is never wrapped back to zero at 360. Letting the number climb keeps every turn going the same way, so the scan never rewinds in front of the reader. Flipping is one sign change on scaleX, and that state is written into aria-pressed, so people who cannot see the highlight still know the picture is mirrored.
var rfAngle = 0, rfMirror = 1;
function rfPaint() {
rfPaper.style.transform = 'rotate(' + rfAngle + 'deg) scaleX(' + rfMirror + ')';
rfFlip.setAttribute('aria-pressed', rfMirror === -1 ? 'true' : 'false');
}
document.getElementById('rf-turn').addEventListener('click', function () {
rfAngle += 90;
rfPaint();
});
rfFlip.addEventListener('click', function () {
rfMirror = -rfMirror;
rfPaint();
});
06Lightbox that grows out of its thumbnail
The panel is laid out where it finally belongs, then pushed backward by its distance from the pressed thumbnail and released. The transition has to be off while that offset is applied, and it can only be switched back on one requestAnimationFrame later. Do both in the same frame and the browser collapses the two values into one, leaving no animation at all.
function lbOpen(i) {
lb.setAttribute('data-i', String(i));
lb.classList.add('is-open');
var from = lbThumbs[i - 1].getBoundingClientRect();
var to = lbPanel.getBoundingClientRect();
lbPanel.style.transition = 'none';
lbPanel.style.transform = 'translate(' + (from.left - to.left + (from.width - to.width) / 2) + 'px,'
+ (from.top - to.top + (from.height - to.height) / 2) + 'px) scale('
+ (from.width / to.width) + ',' + (from.height / to.height) + ')';
requestAnimationFrame(function () {
lbPanel.style.transition = '';
lbPanel.style.transform = '';
document.getElementById('lb-x').focus({ preventScroll: true });
});
}
07360-degree view you drag around
Twelve cells sit in one row, and the window shows exactly one. Drag distance divided by 18 and rounded becomes the cell number, and a doubled modulo folds negatives back into the range 0 to 11. The handle's horizontal offset and the logo's squash come from sine and cosine values written per cell, which is how a full turn happens without twelve real photographs.
var SP_N = 12, SP_STEP = 18;
function spPaint() {
spI = ((spI % SP_N) + SP_N) % SP_N;
spTrack.style.transform = 'translateX(' + (-spI * 100 / SP_N) + '%)';
}
spWin.addEventListener('pointerdown', function (e) {
spDrag = { x: e.clientX, i: spI };
spWin.setPointerCapture(e.pointerId);
});
spWin.addEventListener('pointermove', function (e) {
if (!spDrag) return;
spI = spDrag.i - Math.round((e.clientX - spDrag.x) / SP_STEP);
spPaint();
});
08Spotlight that lights up under the pointer
Two copies of the same artwork are stacked, and only the lower one is drained to gray. The upper copy survives only inside the circle fed to mask-image, so moving mask-position is enough to move the bright area. Because mask-size pins the circle, the script has only two numbers to work out.
.sl__lit {
-webkit-mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
mask-image: radial-gradient(circle closest-side, #000 0 58%, transparent 100%);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 128px 128px;
mask-size: 128px 128px;
-webkit-mask-position: 12px -6px;
mask-position: 12px -6px;
}
09Caption pins stuck onto the picture
Each pin is a <button> rather than a <div>, so the Tab key alone reaches all three. Open and closed live in aria-expanded instead of a class, and the rule that reveals the note uses that attribute as its selector. There is then no way for the announced state and the visible state to disagree.
function hsOpen(pin) {
var was = pin.getAttribute('aria-expanded') === 'true';
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
pin.setAttribute('aria-expanded', was ? 'false' : 'true');
}
hsPins.forEach(function (pin) {
pin.addEventListener('click', function () { hsOpen(pin); });
});
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
hsPins.forEach(function (p) { p.setAttribute('aria-expanded', 'false'); });
});
Where it breaks — the trap
The most common break in a viewer is an autoplay path that has drifted away from the path a real hand takes. All nine tiles in the grid run on a loop, and if that loop lives only in CSS keyframes, the screen empties the moment a pointer arrives and the loop class is dropped. The lens in 01 was exactly that case: the keyframes owned both transform and background-position, so the same two values had to be written as plain rules first and the loop layered on top. Every one of the nine was then screenshotted once with the loop removed, to see what a visitor sees.
The second trap is which corner counts as zero. Pointer coordinates start at the top left of the window, while coordinates inside the artwork start at the top left of the artwork, so without subtracting left and top from getBoundingClientRect, the lens and the spotlight trail the finger by tens of pixels. The lightbox in 06 runs the same measurement twice: once on the thumbnail, once on the open panel, using the difference as its inverted offset and adding half of each size difference so the two centers line up.
The last one shows up when the screen shrinks to palm size. These demos are embedded as 480×300 iframes and drop to 320×200 on a phone. Take away the stage padding and there are 294px across and 174px down, and measuring at 320px gave eight of the nine an identical 288px width (05 being the exception) with heights between 144px and 168px. The tallest, 01 and 02, leave six pixels of slack. Narrow-screen rules therefore only ever shrink the spacing, never grow it. All nine live in one folder whose archive password is ce7bbmt2, and the vanilla files you are looking at ship alongside React ports of the same behavior.
Accessibility (reduced-motion)
A viewer that needs a mouse is not finished. Magnifying and panning are on the arrow keys, the ratio is on buttons, opening and closing are on Enter and Escape, and prefers-reduced-motion: reduce stops the autoplay while leaving the state it was showing in place. Turning off motion must not also turn off the fact that the picture is currently at 2x.
| Item | Mouse | Keyboard | Screen reader |
|---|---|---|---|
| 01 Magnifier lens | Pointer move | Arrow keys, 16px a press | role="img" plus usage hint |
| 02 Wheel zoom and pan | Wheel and drag | Arrow keys plus + and - |
role="img" plus usage hint |
| 03 Minimap | Drag | Arrow keys | role="img" plus usage hint |
| 04 Zoom control | Three buttons | Tab and Enter | aria-live reads the new ratio |
| 05 Rotate and flip | Two buttons | Tab and Enter | aria-pressed marks the mirror |
| 06 Lightbox | Thumbnail click | Escape and left/right arrows | role="dialog" plus a close button |
| 07 360-degree view | Drag | Left and right arrows | role="img" plus usage hint |
| 08 Spotlight | Pointer move | Arrow keys, 20px a press | role="img" plus usage hint |
| 09 Caption pins | Pin click | Tab, Enter, Escape | aria-expanded marks the open pin |
Item 06 is the only one of the nine that moves focus at all: to the close button when the lightbox opens and back to the pressed thumbnail when it closes, both through focus({ preventScroll: true }). Since the demo sits inside an iframe, dropping that option drags the host page down to the element. Browser support for masking, and where the prefixed property is still needed, is documented in the MDN mask-image page. Related screens are collected under the media category, and the ones that respond to a hovering pointer are under the hover category.
FAQ
Wouldn't a library be better for image zoom?
All nine came in under sixty lines each, with no dependencies. Viewer libraries usually bring pinch zoom, inertial scrolling, and multi-image paging in one bundle, and on a product page that needs a single lens over a single photo, that whole bundle is dead weight. The calculation changes when the job involves changing the picture itself, such as saving a rotated original or converting formats; that is a job for a real imaging tool.
How do I stop a picture from going blurry when it is magnified?
These nine are drawn with gradients, so they are recomputed at any size and stay crisp. With real photographs, the only answer is to keep an original larger than its displayed size and swap it in while zoomed. On a lens-only screen like 01, you can give the big original to the lens background alone and leave the base layer as the small file.
Does the same code work on touch screens?
pointermove and pointerdown cover mouse, pen, and finger under one name, so 02, 03, and 07 drag as written. You do need touch-action: none on the box so the page does not scroll along with the finger, and two-finger pinch would mean tracking two pointermove streams yourself, which is why it is not in these nine. Instead, 02 reaches the same scale with the + and - keys, and 04 reaches it with buttons.