UI Components Hard
Pinch-zoom Image
A dependency-free two-pointer pinch surface with bounded scale and pan feedback.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--text: #eef2ff;
--muted: #a8b5d1;
--accent: #78d9c8;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(1100px 560px at 50% -12%, #17223c 0%, var(--bg) 62%);
color: var(--text);
font: 15px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
button, input { font: inherit; }
.demo {
width: min(720px, 100%);
margin: auto;
display: grid;
gap: 1.1rem;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0006;
}
.demo__head h1 { margin: 0 0 0.35rem; font-size: 1.35rem; letter-spacing: -0.01em; }
.demo__head p { margin: 0; font-size: 0.9rem; }
.muted { color: var(--muted); }
kbd {
font: 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
background: #1b2233;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 2px 5px;
}
.viewport {
position: relative;
aspect-ratio: 4 / 3;
overflow: hidden;
border-radius: 16px;
border: 1px solid var(--line);
background: #080d18;
box-shadow: inset 0 0 0 1px #ffffff08;
touch-action: none;
cursor: grab;
outline: none;
user-select: none;
}
.viewport:focus-visible { box-shadow: 0 0 0 3px #78d9c855; }
.viewport[data-panning="true"] { cursor: grabbing; }
.canvas {
position: absolute;
inset: 0;
transform-origin: 0 0;
will-change: transform;
}
.canvas[data-animating="true"] { transition: transform 220ms cubic-bezier(0.22, 0.61, 0.36, 1); }
.art { display: block; width: 100%; height: 100%; }
.hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
padding: 5px 10px;
border-radius: 999px;
font-size: 12px;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #08121a;
background: var(--accent);
transition: opacity 200ms ease;
pointer-events: none;
}
.hint[data-faded="true"] { opacity: 0; }
.bar {
display: flex;
align-items: center;
gap: 0.9rem;
flex-wrap: wrap;
padding: 0.75rem 0.9rem;
background: #18233b;
border: 1px solid #2b3d62;
border-radius: 14px;
}
.zoom {
display: flex;
align-items: center;
gap: 0.65rem;
flex: 1 1 260px;
font-size: 0.85rem;
color: var(--muted);
}
.zoom input[type="range"] { flex: 1; accent-color: var(--accent); }
.zoom output {
font: 13px/1 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--text);
min-width: 5ch;
text-align: right;
}
.btn {
border: 1px solid #33405c;
background: #1a2132;
color: var(--text);
border-radius: 10px;
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
cursor: pointer;
transition: background 150ms ease;
}
.btn:hover { background: #232c42; }
.status {
margin: 0;
font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
color: var(--muted);
}
@media (prefers-reduced-motion: reduce) {
.canvas[data-animating="true"] { transition: none; }
.hint { transition: none; }
}/**
* Pinch-zoom surface: two-pointer pinch, one-pointer pan, wheel zoom, keyboard fallback.
* Transform model: screen = point * scale + offset, applied as translate() scale().
*/
const viewport = document.getElementById("viewport");
const canvas = document.getElementById("canvas");
const range = document.getElementById("zoomRange");
const zoomOut = document.getElementById("zoomOut");
const status = document.getElementById("status");
const resetBtn = document.getElementById("reset");
const hint = document.getElementById("hint");
const MIN = 1;
const MAX = 6;
let scale = 1;
let x = 0;
let y = 0;
/** Active pointers, keyed by pointerId. */
const pointers = new Map();
/** Snapshot of the pinch gesture when the second pointer lands. */
let pinch = null;
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
/** Keep the image covering the viewport: offsets bounded by the overflow at current scale. */
function clampOffsets() {
const r = viewport.getBoundingClientRect();
const maxX = r.width * (scale - 1);
const maxY = r.height * (scale - 1);
x = clamp(x, -maxX, 0);
y = clamp(y, -maxY, 0);
}
function render() {
clampOffsets();
canvas.style.transform = `translate(${x.toFixed(2)}px, ${y.toFixed(2)}px) scale(${scale.toFixed(4)})`;
range.value = String(scale);
zoomOut.textContent = `${scale.toFixed(2)}×`;
status.textContent = `scale ${scale.toFixed(2)}× · offset ${Math.round(x)}, ${Math.round(y)}`;
}
/** Zoom to `next`, keeping the viewport-local point (px, py) anchored under the fingers/cursor. */
function zoomAt(next, px, py) {
const target = clamp(next, MIN, MAX);
const ratio = target / scale;
x = px - (px - x) * ratio;
y = py - (py - y) * ratio;
scale = target;
render();
}
function localPoint(clientX, clientY) {
const r = viewport.getBoundingClientRect();
return [clientX - r.left, clientY - r.top];
}
function animate(fn) {
canvas.dataset.animating = "true";
fn();
const done = () => {
canvas.dataset.animating = "false";
canvas.removeEventListener("transitionend", done);
};
canvas.addEventListener("transitionend", done);
setTimeout(done, 320);
}
/* ---------- pointer gestures ---------- */
viewport.addEventListener("pointerdown", (e) => {
viewport.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
hint.dataset.faded = "true";
canvas.dataset.animating = "false";
if (pointers.size === 1) {
viewport.dataset.panning = "true";
} else if (pointers.size === 2) {
pinch = snapshot();
}
});
function snapshot() {
const [a, b] = [...pointers.values()];
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
const [cx, cy] = localPoint((a.x + b.x) / 2, (a.y + b.y) / 2);
return { dist, cx, cy, scale, x, y };
}
viewport.addEventListener("pointermove", (e) => {
const prev = pointers.get(e.pointerId);
if (!prev) return;
const dx = e.clientX - prev.x;
const dy = e.clientY - prev.y;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size >= 2 && pinch) {
const [a, b] = [...pointers.values()];
const dist = Math.hypot(a.x - b.x, a.y - b.y) || 1;
const [cx, cy] = localPoint((a.x + b.x) / 2, (a.y + b.y) / 2);
// Scale relative to the gesture start, then re-anchor on the moving midpoint
// so the pinch also pans (two-finger drag) naturally.
const target = clamp(pinch.scale * (dist / pinch.dist), MIN, MAX);
const ratio = target / pinch.scale;
x = cx - (pinch.cx - pinch.x) * ratio;
y = cy - (pinch.cy - pinch.y) * ratio;
scale = target;
render();
} else if (pointers.size === 1 && scale > MIN) {
x += dx;
y += dy;
render();
}
});
function endPointer(e) {
if (!pointers.delete(e.pointerId)) return;
if (viewport.hasPointerCapture(e.pointerId)) viewport.releasePointerCapture(e.pointerId);
if (pointers.size < 2) pinch = null;
if (pointers.size === 1) pinch = null; // restart pan cleanly from remaining finger
if (pointers.size === 0) {
viewport.dataset.panning = "false";
// Snap back to fit when the user releases just below 1.05×.
if (scale < 1.05 && scale !== MIN) {
animate(() => {
scale = MIN;
x = 0;
y = 0;
render();
});
}
}
}
["pointerup", "pointercancel", "pointerleave"].forEach((t) =>
viewport.addEventListener(t, endPointer)
);
/* ---------- wheel / trackpad ---------- */
viewport.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const [px, py] = localPoint(e.clientX, e.clientY);
zoomAt(scale * Math.exp(-e.deltaY * 0.0015), px, py);
},
{ passive: false }
);
/* ---------- keyboard fallback ---------- */
viewport.addEventListener("keydown", (e) => {
const step = e.shiftKey ? 60 : 24;
const r = viewport.getBoundingClientRect();
const cx = r.width / 2;
const cy = r.height / 2;
const keys = {
ArrowLeft: () => (x += step),
ArrowRight: () => (x -= step),
ArrowUp: () => (y += step),
ArrowDown: () => (y -= step),
"+": () => zoomAt(scale * 1.25, cx, cy),
"=": () => zoomAt(scale * 1.25, cx, cy),
"-": () => zoomAt(scale / 1.25, cx, cy),
_: () => zoomAt(scale / 1.25, cx, cy),
0: () => animate(() => { scale = 1; x = 0; y = 0; }),
};
const fn = keys[e.key];
if (!fn) return;
e.preventDefault();
hint.dataset.faded = "true";
fn();
render();
});
/* ---------- controls ---------- */
range.addEventListener("input", () => {
const r = viewport.getBoundingClientRect();
zoomAt(Number(range.value), r.width / 2, r.height / 2);
});
resetBtn.addEventListener("click", () => {
animate(() => {
scale = 1;
x = 0;
y = 0;
render();
});
viewport.focus();
});
window.addEventListener("resize", render);
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pinch-zoom Image</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="Pinch-zoom Image">
<header class="demo__head">
<h1>Pinch-zoom Image</h1>
<p class="muted">
Pinch with two pointers to zoom around the midpoint, drag to pan, wheel to zoom at
the cursor. Keyboard: arrows pan, <kbd>+</kbd>/<kbd>-</kbd> zoom, <kbd>0</kbd> resets.
</p>
</header>
<div
class="viewport"
id="viewport"
role="application"
tabindex="0"
aria-label="Zoomable image. Arrow keys pan, plus and minus zoom, zero resets."
>
<div class="canvas" id="canvas">
<svg class="art" viewBox="0 0 400 300" aria-hidden="true">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#1b2a4a" />
<stop offset="100%" stop-color="#4b2f63" />
</linearGradient>
<linearGradient id="ridge" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#2f8f7f" />
<stop offset="100%" stop-color="#123b3a" />
</linearGradient>
</defs>
<rect width="400" height="300" fill="url(#sky)" />
<circle cx="310" cy="70" r="34" fill="#ffd9a0" opacity="0.9" />
<g fill="#ffffff" opacity="0.7">
<circle cx="40" cy="40" r="1.6" /><circle cx="95" cy="24" r="1.2" />
<circle cx="150" cy="58" r="1.8" /><circle cx="220" cy="30" r="1.1" />
<circle cx="265" cy="120" r="1.4" /><circle cx="60" cy="110" r="1.3" />
</g>
<path d="M0 210 L90 130 L150 195 L215 120 L300 210 L400 150 L400 300 L0 300 Z" fill="url(#ridge)" />
<path d="M0 245 L70 205 L140 250 L230 200 L320 255 L400 220 L400 300 L0 300 Z" fill="#0d2430" />
<text x="200" y="292" text-anchor="middle" font-size="9" fill="#9fd8ff" font-family="monospace">
ZOOM IN FOR DETAIL — 400 × 300
</text>
</svg>
</div>
<p class="hint" id="hint">drag & pinch</p>
</div>
<div class="bar">
<label class="zoom">
<span>Zoom</span>
<input id="zoomRange" type="range" min="1" max="6" step="0.01" value="1" aria-label="Zoom level" />
<output id="zoomOut" for="zoomRange">1.00×</output>
</label>
<button id="reset" type="button" class="btn">Reset</button>
</div>
<p class="status" id="status" role="status" aria-live="polite">scale 1.00× · offset 0, 0</p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function GesturePinchZoom() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>Pinch-zoom Image</h2>
<p>Vanilla behavior maps cleanly to Pointer Events and DOM state.</p>
<div className="stack">
{items.map((item, index) => (
<button
className="item"
key={item}
onClick={() => setItems([...items.slice(0, index), ...items.slice(index + 1), item])}
>
{item}
</button>
))}
</div>
</section>
);
}Pinch-zoom Image
A dependency-free two-pointer pinch surface with bounded scale and pan feedback.
Support notes
The examples use Pointer Events, capture, bounded transforms, and small snap thresholds. Production code should also wire keyboard alternatives and reduced-motion preferences.
Included demo
- Vanilla HTML, CSS, and JavaScript with zero external dependencies.
- React equivalent using the same interaction model.
- A Lab route for trying the behavior in isolation.
Integration checklist
Keep state updates separate from presentation, preserve semantic labels, and add persistence or server callbacks at the boundary where your product needs them.