Zoom In/Out Image ซูมรูปภาพ
A lightbox that zooms with the wheel, buttons, double-click and pinch, and pans by dragging.
Live demo
Everything here talks to the real Spring Boot + SQL Server back end.Click a photo. In the viewer: mouse wheel, + - 0, double-click, drag to pan, pinch with two fingers, ← → for the next photo.
What to notice
- Only a CSS transform (translate + scale) changes, so zoom is smooth.
- Wheel zoom keeps the point under the cursor still.
- Two pointers = pinch; the distance between them sets the scale.
Try it
Source code
3 parts, read from the files in this project
src/main/resources/db/migration/V1__schema.sql
gallery_photo table
CREATE TABLE gallery_photo (
id INT IDENTITY(1,1) NOT NULL CONSTRAINT pk_gallery_photo PRIMARY KEY,
title NVARCHAR(100) NOT NULL,
art_seed INT NOT NULL,
width INT NOT NULL,
height INT NOT NULL
);
src/main/resources/static/css/app.css
Lightbox: only transform changes while zooming
.lightbox { position: fixed; inset: 0; z-index: 450; background: rgb(2 6 23 / .92); display: flex; flex-direction: column; animation: fade-in .2s; color: #fff; }
.lb-bar { display: flex; align-items: center; gap: .4rem; padding: .6rem .9rem; flex-wrap: wrap; }
.lb-bar .lb-title { font-weight: 600; margin-right: auto; font-size: .9rem; }
.lb-bar button { width: 2.4rem; height: 2.4rem; border-radius: .6rem; border: 0; background: rgb(255 255 255 / .1); color: #fff; display: grid; place-items: center; cursor: pointer; }
.lb-bar button:hover { background: rgb(255 255 255 / .2); }
.lb-zoom { font-family: var(--mono); font-size: .8rem; width: 3.5rem; text-align: center; }
.lb-stage { flex: 1; overflow: hidden; position: relative; touch-action: none; cursor: grab; display: grid; place-items: center; }
.lb-stage.panning { cursor: grabbing; }
.lb-stage img { max-width: 92%; max-height: 92%; transform-origin: 0 0; user-select: none; -webkit-user-drag: none; will-change: transform; position: absolute; left: 0; top: 0; }
.lb-hint { text-align: center; font-size: .75rem; color: rgb(255 255 255 / .6); padding: .5rem; }
src/main/resources/static/js/demos.js
Lightbox: scale + translate, zoom around the pointer, pinch with two pointers
demos[328] = () => {
const buttons = $$('#zoomGallery button');
let lb = null, img, scale = 1, x = 0, y = 0, current = 0;
const pointers = new Map();
let pinchStart = null, panStart = null;
function apply() {
img.style.transform = `translate(${x}px, ${y}px) scale(${scale})`;
$('.lb-zoom', lb).textContent = Math.round(scale * 100) + '%';
}
function fit() {
const stage = $('.lb-stage', lb).getBoundingClientRect();
const w = img.naturalWidth || img.width, h = img.naturalHeight || img.height;
scale = Math.min(stage.width * 0.92 / w, stage.height * 0.92 / h, 1);
x = (stage.width - w * scale) / 2; y = (stage.height - h * scale) / 2;
apply();
return scale;
}
function zoomAt(factor, cx, cy) {
const stage = $('.lb-stage', lb).getBoundingClientRect();
const px = cx - stage.left, py = cy - stage.top;
const next = Math.max(0.1, Math.min(8, scale * factor));
// keep the point under the cursor still: p = x + s*u => x' = p - s'*u
x = px - (px - x) * (next / scale);
y = py - (py - y) * (next / scale);
scale = next;
apply();
}
function zoomCenter(factor) { const r = $('.lb-stage', lb).getBoundingClientRect(); zoomAt(factor, r.left + r.width / 2, r.top + r.height / 2); }
function load(i) {
current = (i + buttons.length) % buttons.length;
const b = buttons[current];
$('.lb-title', lb).textContent = `${b.dataset.title} (${current + 1} / ${buttons.length})`;
img.onload = fit;
img.src = `/art/${b.dataset.seed}.svg?w=${b.dataset.w}&h=${b.dataset.h}`;
img.width = b.dataset.w; img.height = b.dataset.h;
}
function open(i, opener) {
lb = document.createElement('div');
lb.className = 'lightbox';
lb.setAttribute('role', 'dialog'); lb.setAttribute('aria-modal', 'true'); lb.setAttribute('aria-label', 'Photo viewer');
lb.innerHTML = `
<div class="lb-bar">
<span class="lb-title"></span>
<button type="button" data-z="out" aria-label="Zoom out">${icon('zoom-out')}</button>
<span class="lb-zoom">100%</span>
<button type="button" data-z="in" aria-label="Zoom in">${icon('zoom')}</button>
<button type="button" data-z="fit" aria-label="Fit to screen">${icon('refresh')}</button>
<button type="button" data-z="prev" aria-label="Previous photo">${icon('chevron-left')}</button>
<button type="button" data-z="next" aria-label="Next photo">${icon('chevron-right')}</button>
<button type="button" data-z="fs" aria-label="Full screen">${icon('maximize')}</button>
<button type="button" data-z="close" aria-label="Close">${icon('x')}</button>
</div>
<div class="lb-stage"><img alt="" draggable="false"></div>
<div class="lb-hint">Wheel / + − to zoom · drag to pan · double-click to zoom · pinch on touch · ← → next · Esc close</div>`;
document.body.appendChild(lb);
document.body.style.overflow = 'hidden';
img = $('img', lb);
const stage = $('.lb-stage', lb);
lb._opener = opener;
load(i);
$('[data-z="close"]', lb).focus();
lb.addEventListener('click', (e) => {
const z = e.target.closest('[data-z]')?.dataset.z;
if (z === 'in') zoomCenter(1.25); else if (z === 'out') zoomCenter(0.8); else if (z === 'fit') fit();
else if (z === 'prev') load(current - 1); else if (z === 'next') load(current + 1);
else if (z === 'fs') UI.fullscreen.toggle(lb); else if (z === 'close') close();
});
stage.addEventListener('wheel', (e) => { e.preventDefault(); zoomAt(e.deltaY < 0 ? 1.15 : 1 / 1.15, e.clientX, e.clientY); }, { passive: false });
stage.addEventListener('dblclick', (e) => { const base = scale; fit(); if (base <= scale * 1.05) zoomAt(2.5, e.clientX, e.clientY); });
stage.addEventListener('pointerdown', (e) => {
stage.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2) {
const [a, b] = [...pointers.values()];
pinchStart = { dist: Math.hypot(a.x - b.x, a.y - b.y), scale };
} else { panStart = { px: e.clientX, py: e.clientY, x, y }; stage.classList.add('panning'); }
});
stage.addEventListener('pointermove', (e) => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size === 2 && pinchStart) {
const [a, b] = [...pointers.values()];
const factor = (pinchStart.scale * Math.hypot(a.x - b.x, a.y - b.y) / pinchStart.dist) / scale;
zoomAt(factor, (a.x + b.x) / 2, (a.y + b.y) / 2);
} else if (panStart) {
x = panStart.x + e.clientX - panStart.px; y = panStart.y + e.clientY - panStart.py; apply();
}
});
const up = (e) => { pointers.delete(e.pointerId); if (pointers.size < 2) pinchStart = null; if (!pointers.size) { panStart = null; stage.classList.remove('panning'); } };
stage.addEventListener('pointerup', up);
stage.addEventListener('pointercancel', up);
lb.addEventListener('keydown', keys);
addEventListener('resize', fit);
}
function keys(e) {
const k = e.key;
if (k === '+' || k === '=') zoomCenter(1.25); else if (k === '-') zoomCenter(0.8); else if (k === '0') fit();
else if (k === 'ArrowRight') load(current + 1); else if (k === 'ArrowLeft') load(current - 1);
else if (k === 'Escape') { if (!document.fullscreenElement) close(); return; }
else if (k === 'Tab') { // keep focus inside the viewer
const f = $$('button', lb), i = f.indexOf(document.activeElement);
e.preventDefault(); f[(i + (e.shiftKey ? -1 : 1) + f.length) % f.length].focus(); return;
} else return;
e.preventDefault();
e.stopPropagation();
}
function close() {
removeEventListener('resize', fit);
document.body.style.overflow = '';
const opener = lb._opener;
lb.remove(); lb = null;
opener?.focus();
}
buttons.forEach((b, i) => b.addEventListener('click', () => open(i, b)));
};