UI Components Hard
Pinch Zoom
Pinch-to-zoom and pan image viewer. Two-finger spread zooms in; single-finger drag pans when zoomed. Double-tap resets. No libraries.
Open in Lab
MCP
vanilla-js css
Targets: JS HTML
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #111;
min-height: 100vh;
display: flex;
align-items: flex-start;
justify-content: center;
color: #fff;
}
.page {
width: 100%;
max-width: 480px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
padding: 24px 20px 16px;
text-align: center;
}
header h1 {
font-size: 20px;
font-weight: 700;
}
header p {
font-size: 13px;
color: #888;
margin-top: 4px;
}
/* Zoom container */
.zoom-container {
flex: 1;
overflow: hidden;
background: #000;
cursor: grab;
touch-action: none;
user-select: none;
min-height: 400px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.zoom-container:active {
cursor: grabbing;
}
.zoom-target {
transform-origin: center center;
transition: transform 0.1s ease;
will-change: transform;
line-height: 0;
}
.zoom-target.no-transition {
transition: none;
}
.zoom-target img {
width: 100%;
max-width: 480px;
height: auto;
display: block;
pointer-events: none;
-webkit-user-drag: none;
}
/* Controls */
.controls {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
padding: 16px 20px 24px;
background: #1a1a1a;
}
.ctrl-btn {
width: 44px;
height: 44px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.07);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.ctrl-btn:hover {
background: rgba(255, 255, 255, 0.14);
}
.ctrl-btn:active {
background: rgba(255, 255, 255, 0.2);
}
.ctrl-btn svg {
width: 18px;
height: 18px;
}
.ctrl-btn.reset {
width: auto;
padding: 0 16px;
font-size: 13px;
font-weight: 600;
color: #a5b4fc;
}
.zoom-level {
font-size: 14px;
font-weight: 600;
color: #fff;
min-width: 48px;
text-align: center;
}const container = document.getElementById("zoomContainer");
const target = document.getElementById("zoomTarget");
const zoomLevelEl = document.getElementById("zoomLevel");
const zoomInBtn = document.getElementById("zoomIn");
const zoomOutBtn = document.getElementById("zoomOut");
const resetBtn = document.getElementById("resetBtn");
const MIN_SCALE = 1;
const MAX_SCALE = 5;
const ZOOM_STEP = 0.5;
let scale = 1;
let tx = 0;
let ty = 0;
let lastScale = 1;
let lastTx = 0;
let lastTy = 0;
// Double-tap detection
let lastTap = 0;
function applyTransform(animated = false) {
target.style.transition = animated ? "transform 0.25s ease" : "none";
target.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`;
zoomLevelEl.textContent = `${scale.toFixed(1)}×`;
}
function clampPan() {
if (scale <= 1) {
tx = 0;
ty = 0;
return;
}
const rect = container.getBoundingClientRect();
const maxTx = (rect.width * (scale - 1)) / 2;
const maxTy = (rect.height * (scale - 1)) / 2;
tx = Math.max(-maxTx, Math.min(maxTx, tx));
ty = Math.max(-maxTy, Math.min(maxTy, ty));
}
function resetZoom() {
scale = 1;
tx = 0;
ty = 0;
applyTransform(true);
}
// Touch events
let initialPinchDist = 0;
function getTouchDist(touches) {
const dx = touches[0].clientX - touches[1].clientX;
const dy = touches[0].clientY - touches[1].clientY;
return Math.sqrt(dx * dx + dy * dy);
}
function getMidpoint(touches) {
return {
x: (touches[0].clientX + touches[1].clientX) / 2,
y: (touches[0].clientY + touches[1].clientY) / 2,
};
}
let panStartX = 0;
let panStartY = 0;
let isPanning = false;
container.addEventListener(
"touchstart",
(e) => {
if (e.touches.length === 2) {
// Pinch start
isPanning = false;
initialPinchDist = getTouchDist(e.touches);
lastScale = scale;
lastTx = tx;
lastTy = ty;
} else if (e.touches.length === 1) {
// Pan start or double-tap detection
const now = Date.now();
if (now - lastTap < 300) {
// Double tap — reset
resetZoom();
lastTap = 0;
return;
}
lastTap = now;
if (scale > 1) {
isPanning = true;
panStartX = e.touches[0].clientX - tx;
panStartY = e.touches[0].clientY - ty;
}
}
},
{ passive: true }
);
container.addEventListener(
"touchmove",
(e) => {
e.preventDefault();
if (e.touches.length === 2) {
// Pinch zoom
const dist = getTouchDist(e.touches);
scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, lastScale * (dist / initialPinchDist)));
clampPan();
applyTransform();
} else if (e.touches.length === 1 && isPanning) {
// Pan
tx = e.touches[0].clientX - panStartX;
ty = e.touches[0].clientY - panStartY;
clampPan();
applyTransform();
}
},
{ passive: false }
);
container.addEventListener("touchend", () => {
isPanning = false;
});
// Keyboard zoom
document.addEventListener("keydown", (e) => {
if (e.key === "+" || e.key === "=") {
scale = Math.min(MAX_SCALE, scale + ZOOM_STEP);
clampPan();
applyTransform(true);
} else if (e.key === "-") {
scale = Math.max(MIN_SCALE, scale - ZOOM_STEP);
clampPan();
applyTransform(true);
}
});
// Button controls
zoomInBtn.addEventListener("click", () => {
scale = Math.min(MAX_SCALE, scale + ZOOM_STEP);
clampPan();
applyTransform(true);
});
zoomOutBtn.addEventListener("click", () => {
scale = Math.max(MIN_SCALE, scale - ZOOM_STEP);
clampPan();
applyTransform(true);
});
resetBtn.addEventListener("click", resetZoom);<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
<link rel="stylesheet" href="style.css" />
<title>Pinch Zoom</title>
</head>
<body>
<div class="page">
<header>
<h1>Pinch Zoom</h1>
<p>Pinch to zoom · Drag to pan · Double-tap to reset</p>
</header>
<div
class="zoom-container"
id="zoomContainer"
role="img"
aria-label="Zoomable image. Use pinch gesture or + / - keys to zoom."
>
<div class="zoom-target" id="zoomTarget">
<img
src="https://images.unsplash.com/photo-1506905925346-21bda4d32df4?w=800&q=80"
alt="Mountain landscape"
draggable="false"
/>
</div>
</div>
<div class="controls">
<button class="ctrl-btn" id="zoomOut" aria-label="Zoom out">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<div class="zoom-level" id="zoomLevel">1.0×</div>
<button class="ctrl-btn" id="zoomIn" aria-label="Zoom in">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/><line x1="11" y1="8" x2="11" y2="14"/><line x1="8" y1="11" x2="14" y2="11"/></svg>
</button>
<button class="ctrl-btn reset" id="resetBtn" aria-label="Reset zoom">Reset</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>Pinch Zoom
A full pinch-to-zoom image viewer. Use two fingers to zoom in and out, one finger to pan while zoomed, and double-tap to reset to the original size.
How it works
touchstartwith two touches records the initial distance between fingerstouchmovewith two touches computes the new distance and setsscaleviatransform- Scale is clamped between 1× and 5×
- Single-finger
touchmovewhenscale > 1pans the image by updatingtranslateX/translateY - Pan boundaries are constrained so the image cannot be dragged fully off screen
- Double-tap (
touchendwithin 300ms of previous tap) resets scale and position
Accessibility
- Add
role="img"and a descriptivearia-labelon the container for screen readers - Keyboard zoom via
+/-keys is included as a fallback
When to use it
- Product image detail pages
- Mobile photo viewers and galleries
- Map or diagram viewers