UI Components Hard
SVG World Pan Zoom
Pan and zoom an inline SVG world-like map with Pointer Events and a bounded viewBox transform.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--ink: #eef2ff;
--muted: #9eb1d4;
--accent: #4ecdc4;
color-scheme: dark;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: clamp(1rem, 4vw, 3rem);
background: radial-gradient(120% 90% at 50% 0%, #16203a 0%, var(--bg) 60%);
color: var(--ink);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}
button { font: inherit; }
.demo {
width: min(920px, 100%);
background: var(--panel);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.45);
}
.demo h1 { margin: 0 0 .25rem; font-size: 1.35rem; }
.muted { color: var(--muted); margin: 0 0 1.25rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; }
.map { position: relative; }
.map__toolbar {
position: absolute;
z-index: 2;
top: 12px;
left: 12px;
display: flex;
gap: 6px;
align-items: center;
}
.btn {
width: 34px;
height: 34px;
border-radius: 9px;
border: 1px solid var(--line);
background: rgba(10, 16, 30, 0.85);
backdrop-filter: blur(6px);
color: var(--ink);
font-size: 16px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
}
.btn--wide { width: auto; padding: 0 12px; font-size: 13px; }
.btn:hover { background: rgba(78, 205, 196, 0.16); border-color: var(--accent); }
.btn:active { transform: translateY(1px); }
.zoom {
margin-left: 4px;
padding: 0 10px;
height: 34px;
display: grid;
place-items: center;
border-radius: 9px;
border: 1px solid var(--line);
background: rgba(10, 16, 30, 0.85);
font-variant-numeric: tabular-nums;
font-size: 13px;
color: var(--muted);
}
.map__svg {
display: block;
width: 100%;
aspect-ratio: 2 / 1;
border: 1px solid var(--line);
border-radius: 14px;
background: #0a1022;
cursor: grab;
touch-action: none;
outline: none;
user-select: none;
}
.map__svg.is-panning { cursor: grabbing; }
.map__svg:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(78, 205, 196, .45); }
.grid path {
fill: none;
stroke: rgba(255, 255, 255, 0.06);
stroke-width: 1;
vector-effect: non-scaling-stroke;
}
.land path {
stroke: rgba(120, 240, 226, 0.45);
stroke-width: 1.2;
vector-effect: non-scaling-stroke;
fill-opacity: .9;
transition: fill-opacity 150ms ease;
}
.land path:hover { fill-opacity: 1; }
.city {
fill: #ffd166;
stroke: rgba(0, 0, 0, 0.55);
stroke-width: 1;
vector-effect: non-scaling-stroke;
cursor: pointer;
}
.city.is-active { fill: var(--accent); }
.hint { margin: .75rem 2px 0; color: var(--muted); font-size: .85rem; }
kbd {
border: 1px solid var(--line);
border-radius: 4px;
padding: 0 4px;
background: rgba(255, 255, 255, 0.05);
font-size: 11px;
}
@media (prefers-reduced-motion: reduce) {
* { transition: none !important; }
}(() => {
const svg = document.getElementById("world-svg");
const readout = document.getElementById("zoom-readout");
const toolbar = document.querySelector(".map__toolbar");
if (!svg) return;
// World extent = the coordinate space the map is drawn in.
const WORLD = { x: 0, y: 0, w: 1000, h: 500 };
const MIN_SCALE = 1; // never zoom out past the whole world
const MAX_SCALE = 8;
// view = current viewBox. scale === WORLD.w / view.w
const view = { ...WORLD };
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
function apply() {
// keep the view inside the world extent
view.x = clamp(view.x, WORLD.x, WORLD.x + WORLD.w - view.w);
view.y = clamp(view.y, WORLD.y, WORLD.y + WORLD.h - view.h);
svg.setAttribute("viewBox", `${view.x} ${view.y} ${view.w} ${view.h}`);
if (readout) readout.textContent = `${Math.round((WORLD.w / view.w) * 100)}%`;
}
/** Convert a client point to world coordinates under the current viewBox. */
function toWorld(clientX, clientY) {
const r = svg.getBoundingClientRect();
return {
x: view.x + ((clientX - r.left) / r.width) * view.w,
y: view.y + ((clientY - r.top) / r.height) * view.h,
};
}
/**
* Zoom by `factor` keeping the world point currently under (clientX, clientY)
* anchored to that same screen position.
*/
function zoomAt(factor, clientX, clientY) {
const scale = WORLD.w / view.w;
const next = clamp(scale * factor, MIN_SCALE, MAX_SCALE);
if (next === scale) return;
const r = svg.getBoundingClientRect();
const px = clientX == null ? 0.5 : (clientX - r.left) / r.width;
const py = clientY == null ? 0.5 : (clientY - r.top) / r.height;
const anchor = {
x: view.x + px * view.w,
y: view.y + py * view.h,
};
view.w = WORLD.w / next;
view.h = WORLD.h / next;
view.x = anchor.x - px * view.w;
view.y = anchor.y - py * view.h;
apply();
}
function panBy(dxWorld, dyWorld) {
view.x += dxWorld;
view.y += dyWorld;
apply();
}
function reset() {
Object.assign(view, WORLD);
apply();
}
/* ---------- pointer: drag to pan, two-finger pinch to zoom ---------- */
const pointers = new Map();
let dragOrigin = null; // { world, view }
let pinch = null; // { dist, cx, cy }
const pointerList = () => [...pointers.values()];
svg.addEventListener("pointerdown", (e) => {
svg.setPointerCapture(e.pointerId);
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
svg.classList.add("is-panning");
if (pointers.size === 1) {
dragOrigin = { world: toWorld(e.clientX, e.clientY), vx: view.x, vy: view.y };
} else if (pointers.size === 2) {
dragOrigin = null;
pinch = measurePinch();
}
});
function measurePinch() {
const [a, b] = pointerList();
return {
dist: Math.hypot(a.x - b.x, a.y - b.y) || 1,
cx: (a.x + b.x) / 2,
cy: (a.y + b.y) / 2,
};
}
svg.addEventListener("pointermove", (e) => {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size >= 2 && pinch) {
const now = measurePinch();
zoomAt(now.dist / pinch.dist, now.cx, now.cy);
// pan with the pinch centroid too
const r = svg.getBoundingClientRect();
panBy(
-((now.cx - pinch.cx) / r.width) * view.w,
-((now.cy - pinch.cy) / r.height) * view.h,
);
pinch = now;
return;
}
if (dragOrigin) {
const r = svg.getBoundingClientRect();
// recompute pan so the grabbed world point stays under the cursor
const px = (e.clientX - r.left) / r.width;
const py = (e.clientY - r.top) / r.height;
view.x = dragOrigin.world.x - px * view.w;
view.y = dragOrigin.world.y - py * view.h;
apply();
}
});
function release(e) {
pointers.delete(e.pointerId);
if (pointers.size < 2) pinch = null;
if (pointers.size === 0) {
dragOrigin = null;
svg.classList.remove("is-panning");
} else if (pointers.size === 1) {
const [p] = pointerList();
dragOrigin = { world: toWorld(p.x, p.y) };
}
}
svg.addEventListener("pointerup", release);
svg.addEventListener("pointercancel", release);
/* ---------- wheel zoom ---------- */
svg.addEventListener(
"wheel",
(e) => {
e.preventDefault();
const factor = Math.exp(-e.deltaY * 0.0015);
zoomAt(factor, e.clientX, e.clientY);
},
{ passive: false },
);
/* ---------- keyboard fallback ---------- */
svg.addEventListener("keydown", (e) => {
const step = view.w * 0.12;
const keys = {
ArrowLeft: () => panBy(-step, 0),
ArrowRight: () => panBy(step, 0),
ArrowUp: () => panBy(0, -step * (WORLD.h / WORLD.w)),
ArrowDown: () => panBy(0, step * (WORLD.h / WORLD.w)),
"+": () => zoomAt(1.25),
"=": () => zoomAt(1.25),
"-": () => zoomAt(1 / 1.25),
_: () => zoomAt(1 / 1.25),
0: reset,
};
const fn = keys[e.key];
if (!fn) return;
e.preventDefault();
fn();
});
/* ---------- toolbar ---------- */
toolbar?.addEventListener("click", (e) => {
const act = e.target.closest("[data-act]")?.dataset.act;
if (act === "in") zoomAt(1.35);
else if (act === "out") zoomAt(1 / 1.35);
else if (act === "reset") reset();
});
/* ---------- city selection ---------- */
svg.querySelectorAll(".city").forEach((city) => {
city.addEventListener("click", (e) => {
e.stopPropagation();
svg.querySelectorAll(".city.is-active").forEach((c) => c.classList.remove("is-active"));
city.classList.add("is-active");
});
});
apply();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG World Pan Zoom</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="SVG World Pan Zoom">
<h1>SVG world pan / zoom</h1>
<p class="muted">Bounded <code>viewBox</code> transform driven by Pointer Events.</p>
<section class="map" aria-label="Interactive abstract world map">
<div class="map__toolbar" role="toolbar" aria-label="Map controls">
<button type="button" class="btn" data-act="in" aria-label="Zoom in">+</button>
<button type="button" class="btn" data-act="out" aria-label="Zoom out">−</button>
<button type="button" class="btn btn--wide" data-act="reset">Reset</button>
<output class="zoom" id="zoom-readout" aria-live="polite">100%</output>
</div>
<svg
id="world-svg"
class="map__svg"
viewBox="0 0 1000 500"
role="application"
tabindex="0"
aria-describedby="map-help"
preserveAspectRatio="xMidYMid meet"
>
<defs>
<linearGradient id="sea" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#0d1730" />
<stop offset="100%" stop-color="#0a1022" />
</linearGradient>
<linearGradient id="land" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#2f6f6a" />
<stop offset="100%" stop-color="#25555f" />
</linearGradient>
</defs>
<rect x="0" y="0" width="1000" height="500" fill="url(#sea)" />
<g class="grid" aria-hidden="true">
<path d="M0 125H1000M0 250H1000M0 375H1000M200 0V500M400 0V500M600 0V500M800 0V500" />
</g>
<g class="land" fill="url(#land)">
<path d="M120 90 L250 70 L300 130 L260 200 L180 230 L110 180 Z"><title>Norlend</title></path>
<path d="M215 250 L275 245 L300 330 L260 430 L225 420 L210 330 Z"><title>Sudrica</title></path>
<path d="M470 80 L560 60 L600 110 L555 150 L480 145 Z"><title>Evrast</title></path>
<path d="M470 175 L545 165 L570 250 L520 330 L470 300 L455 220 Z"><title>Afrenn</title></path>
<path d="M620 90 L820 60 L900 120 L860 210 L700 200 L620 150 Z"><title>Asvara</title></path>
<path d="M800 330 L890 320 L910 390 L840 410 L790 375 Z"><title>Oceana</title></path>
</g>
<g class="cities">
<circle class="city" cx="205" cy="150" r="6"><title>Northport</title></circle>
<circle class="city" cx="252" cy="345" r="6"><title>Sur Bay</title></circle>
<circle class="city" cx="512" cy="115" r="6"><title>Meridian</title></circle>
<circle class="city" cx="742" cy="140" r="6"><title>Kalvet</title></circle>
<circle class="city" cx="852" cy="368" r="6"><title>Ardua</title></circle>
</g>
</svg>
<p class="hint" id="map-help">
Drag to pan, wheel or pinch to zoom. With the map focused: arrow keys pan,
<kbd>+</kbd>/<kbd>−</kbd> zoom, <kbd>0</kbd> resets.
</p>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgWorldPanZoom() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>SVG World Pan Zoom</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>
);
}SVG World Pan Zoom
Pan and zoom an inline SVG world-like map with Pointer Events and a bounded viewBox transform.
Support notes
Inline SVG keeps the map demo portable and avoids a tile server. Replace the abstract shapes with licensed geographic paths and project coordinates before shipping a real map.
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.