UI Components Easy
SVG World Marker
Drop and select labeled markers on a pure SVG map with pointer and keyboard interactions.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
*,
*::before,
*::after {
box-sizing: border-box;
}
:root {
--bg: #0b1020;
--surface: #121a2d;
--surface-2: #18233b;
--line: #2b3d62;
--text: #eef2ff;
--muted: #9eb1d4;
--accent: #78a9ff;
--pin: #ff8a6a;
}
body {
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
button,
input {
font: inherit;
}
.demo {
width: min(1000px, 100%);
margin: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0004;
display: grid;
gap: 1.25rem;
}
.demo__head h1 {
margin: 0 0 0.35rem;
font-size: 1.35rem;
letter-spacing: -0.01em;
}
.demo__head p {
margin: 0;
color: var(--muted);
font-size: 0.88rem;
}
kbd {
font: inherit;
font-size: 0.8em;
background: #ffffff14;
border: 1px solid var(--line);
border-radius: 5px;
padding: 1px 5px;
}
/* ---------- map ---------- */
.map-wrap {
position: relative;
border: 1px solid var(--line);
border-radius: 16px;
overflow: hidden;
background: var(--surface-2);
}
.map {
display: block;
width: 100%;
height: auto;
cursor: crosshair;
touch-action: manipulation;
}
.map:focus-visible {
outline: 2px solid var(--accent);
outline-offset: -2px;
}
.grid path {
fill: none;
stroke: #ffffff0d;
stroke-width: 1;
}
.land path {
fill: #1d3346;
stroke: #2e4c66;
stroke-width: 1.5;
stroke-linejoin: round;
}
/* ---------- markers ---------- */
.marker {
cursor: pointer;
}
.marker .halo {
fill: var(--pin);
opacity: 0.18;
transition: opacity 160ms ease;
}
.marker .pin {
fill: var(--pin);
stroke: #0b1020;
stroke-width: 2;
transform-box: fill-box;
transform-origin: 50% 100%;
transition: transform 160ms ease;
}
.marker .tag {
fill: #eaf2ff;
font-family: inherit;
font-size: 15px;
paint-order: stroke;
stroke: #0b1020d9;
stroke-width: 4;
pointer-events: none;
}
.marker:hover .pin,
.marker:focus-visible .pin {
transform: scale(1.25);
}
.marker:focus-visible {
outline: none;
}
.marker:focus-visible .halo {
opacity: 0.45;
stroke: var(--accent);
stroke-width: 2;
}
.marker.is-selected .pin {
fill: var(--accent);
}
.marker.is-selected .halo {
fill: var(--accent);
opacity: 0.35;
}
.hint {
position: absolute;
left: 12px;
bottom: 12px;
margin: 0;
font-size: 0.78rem;
color: var(--muted);
background: #0b1020bf;
border: 1px solid var(--line);
border-radius: 999px;
padding: 4px 12px;
}
/* ---------- panel ---------- */
.panel {
background: var(--surface-2);
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
display: grid;
gap: 0.85rem;
}
.panel__bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.panel__bar h2 {
margin: 0;
font-size: 0.8rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.btn {
font-size: 0.82rem;
color: var(--text);
background: #ffffff0f;
border: 1px solid var(--line);
border-radius: 9px;
padding: 6px 12px;
cursor: pointer;
}
.btn:hover {
background: #ffffff1f;
}
.field {
display: grid;
gap: 0.35rem;
font-size: 0.78rem;
color: var(--muted);
max-width: 280px;
}
.field input {
color: var(--text);
background: #0d1526;
border: 1px solid var(--line);
border-radius: 9px;
padding: 8px 10px;
}
.field input:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 1px;
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.4rem;
}
.list:empty::after {
content: "Nothing dropped yet.";
color: var(--muted);
font-size: 0.85rem;
}
.list li {
display: flex;
align-items: center;
gap: 0.6rem;
border: 1px solid var(--line);
border-radius: 11px;
padding: 8px 10px;
font-size: 0.85rem;
background: #ffffff05;
}
.list li.is-selected {
border-color: var(--accent);
background: #78a9ff1f;
}
.list .dot {
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--pin);
}
.list li.is-selected .dot {
background: var(--accent);
}
.list .coords {
margin-left: auto;
color: var(--muted);
font-size: 0.78rem;
font-variant-numeric: tabular-nums;
}
.list .remove {
color: var(--muted);
background: none;
border: 0;
border-radius: 6px;
padding: 2px 7px;
cursor: pointer;
}
.list .remove:hover {
color: var(--pin);
background: #ff8a6a1f;
}
@media (prefers-reduced-motion: reduce) {
.marker .pin,
.marker .halo {
transition: none;
}
}const SVG_NS = "http://www.w3.org/2000/svg";
const svg = document.getElementById("marker-map");
const layer = document.getElementById("markers");
const list = document.getElementById("list");
const hint = document.getElementById("hint");
const labelInput = document.getElementById("label-input");
const clearBtn = document.getElementById("clear");
/** @type {{id:number,x:number,y:number,label:string}[]} */
let markers = [];
let selectedId = null;
let seq = 0;
/* ---------- geometry ---------- */
/** Convert a pointer event to viewBox (user-space) coordinates. */
function toUserSpace(event) {
const point = svg.createSVGPoint();
point.x = event.clientX;
point.y = event.clientY;
return point.matrixTransform(svg.getScreenCTM().inverse());
}
/** Fake equirectangular projection over the 1000x500 viewBox. */
function toLatLon(x, y) {
return {
lon: (x / 1000) * 360 - 180,
lat: 90 - (y / 500) * 180,
};
}
function formatCoords(x, y) {
const { lat, lon } = toLatLon(x, y);
const ns = lat >= 0 ? "N" : "S";
const ew = lon >= 0 ? "E" : "W";
return `${Math.abs(lat).toFixed(1)}°${ns} ${Math.abs(lon).toFixed(1)}°${ew}`;
}
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
/* ---------- state ---------- */
function addMarker(x, y, label) {
const marker = {
id: ++seq,
x: clamp(x, 8, 992),
y: clamp(y, 8, 492),
label: label || `Marker ${seq}`,
};
markers.push(marker);
selectedId = marker.id;
render();
layer.querySelector(`[data-id="${marker.id}"]`)?.focus();
}
function removeMarker(id) {
const index = markers.findIndex((m) => m.id === id);
if (index === -1) return;
markers.splice(index, 1);
if (selectedId === id) selectedId = markers[index]?.id ?? markers.at(-1)?.id ?? null;
render();
const next = selectedId && layer.querySelector(`[data-id="${selectedId}"]`);
(next || svg).focus();
}
function moveMarker(id, dx, dy) {
const marker = markers.find((m) => m.id === id);
if (!marker) return;
marker.x = clamp(marker.x + dx, 8, 992);
marker.y = clamp(marker.y + dy, 8, 492);
render();
layer.querySelector(`[data-id="${id}"]`)?.focus();
}
/* ---------- rendering ---------- */
function makeMarkerNode(marker) {
const g = document.createElementNS(SVG_NS, "g");
g.setAttribute("class", `marker${marker.id === selectedId ? " is-selected" : ""}`);
g.setAttribute("tabindex", "0");
g.setAttribute("role", "button");
g.dataset.id = String(marker.id);
g.setAttribute(
"aria-label",
`${marker.label} at ${formatCoords(marker.x, marker.y)}. Arrow keys to move, Delete to remove.`,
);
const halo = document.createElementNS(SVG_NS, "circle");
halo.setAttribute("class", "halo");
halo.setAttribute("cx", marker.x);
halo.setAttribute("cy", marker.y);
halo.setAttribute("r", marker.id === selectedId ? 20 : 15);
// Teardrop pin whose tip sits exactly on (x, y).
const pin = document.createElementNS(SVG_NS, "path");
pin.setAttribute("class", "pin");
pin.setAttribute(
"d",
`M${marker.x} ${marker.y} l-8 -13 a9 9 0 1 1 16 0 Z`,
);
const tag = document.createElementNS(SVG_NS, "text");
tag.setAttribute("class", "tag");
tag.setAttribute("x", marker.x + 14);
tag.setAttribute("y", marker.y - 14);
tag.textContent = marker.label;
g.append(halo, pin, tag);
return g;
}
function makeListItem(marker) {
const li = document.createElement("li");
if (marker.id === selectedId) li.className = "is-selected";
const dot = document.createElement("span");
dot.className = "dot";
const name = document.createElement("span");
name.textContent = marker.label;
const coords = document.createElement("span");
coords.className = "coords";
coords.textContent = formatCoords(marker.x, marker.y);
const remove = document.createElement("button");
remove.type = "button";
remove.className = "remove";
remove.textContent = "✕";
remove.setAttribute("aria-label", `Remove ${marker.label}`);
remove.addEventListener("click", () => removeMarker(marker.id));
li.append(dot, name, coords, remove);
li.addEventListener("click", (event) => {
if (event.target === remove) return;
selectedId = marker.id;
render();
layer.querySelector(`[data-id="${marker.id}"]`)?.focus();
});
return li;
}
function render() {
layer.replaceChildren(...markers.map(makeMarkerNode));
list.replaceChildren(...markers.map(makeListItem));
const selected = markers.find((m) => m.id === selectedId);
hint.textContent = !markers.length
? "No markers yet"
: selected
? `${markers.length} marker${markers.length > 1 ? "s" : ""} · selected ${selected.label} (${formatCoords(selected.x, selected.y)})`
: `${markers.length} marker${markers.length > 1 ? "s" : ""}`;
}
/* ---------- interaction ---------- */
svg.addEventListener("pointerdown", (event) => {
const existing = event.target.closest(".marker");
if (existing) {
selectedId = Number(existing.dataset.id);
render();
layer.querySelector(`[data-id="${selectedId}"]`)?.focus();
return;
}
const { x, y } = toUserSpace(event);
addMarker(x, y, labelInput.value.trim());
});
svg.addEventListener("keydown", (event) => {
const node = event.target.closest?.(".marker");
if (node) {
const id = Number(node.dataset.id);
const step = event.shiftKey ? 20 : 5;
const deltas = {
ArrowUp: [0, -step],
ArrowDown: [0, step],
ArrowLeft: [-step, 0],
ArrowRight: [step, 0],
};
if (deltas[event.key]) {
event.preventDefault();
selectedId = id;
moveMarker(id, ...deltas[event.key]);
return;
}
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
removeMarker(id);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
selectedId = id;
render();
layer.querySelector(`[data-id="${id}"]`)?.focus();
}
return;
}
// Focus is on the map itself: drop a marker at the centre (keyboard fallback).
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
const offset = markers.length * 24;
addMarker(500 + (offset % 200) - 100, 250 + (offset % 120) - 60, labelInput.value.trim());
}
});
clearBtn.addEventListener("click", () => {
markers = [];
selectedId = null;
render();
svg.focus();
});
// Seed a couple of markers so the demo is not empty on load.
addMarker(230, 160, "Harbor");
addMarker(700, 170, "Depot");
selectedId = null;
render();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG World Marker</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="SVG World Marker">
<header class="demo__head">
<h1>SVG world markers</h1>
<p>
Click the map to drop a marker. Press <kbd>Enter</kbd> on the map to drop one at the
crosshair, <kbd>Tab</kbd> between markers, arrow keys to nudge, <kbd>Delete</kbd> to remove.
</p>
</header>
<div class="map-wrap">
<svg
id="marker-map"
class="map"
viewBox="0 0 1000 500"
role="application"
aria-label="Abstract world map. Click or press Enter to drop a labeled marker."
tabindex="0"
>
<defs>
<radialGradient id="sea" cx="50%" cy="40%" r="75%">
<stop offset="0%" stop-color="#16344d" />
<stop offset="100%" stop-color="#0a1a27" />
</radialGradient>
</defs>
<rect x="0" y="0" width="1000" height="500" fill="url(#sea)" />
<g class="grid" aria-hidden="true">
<path d="M0 125H1000M0 250H1000M0 375H1000" />
<path d="M200 0V500M400 0V500M600 0V500M800 0V500" />
</g>
<g class="land" aria-hidden="true">
<path d="M120 110 L260 90 L320 150 L280 210 L180 230 L110 180 Z" />
<path d="M200 250 L280 240 L300 330 L250 420 L210 380 L190 310 Z" />
<path d="M430 90 L560 70 L620 120 L580 170 L470 180 L420 140 Z" />
<path d="M450 200 L560 195 L600 280 L540 380 L470 350 L440 270 Z" />
<path d="M640 110 L860 80 L920 160 L820 230 L700 210 L640 160 Z" />
<path d="M790 330 L890 320 L910 390 L820 410 L780 370 Z" />
</g>
<g id="markers" class="markers"></g>
</svg>
<p class="hint" id="hint" role="status" aria-live="polite">No markers yet</p>
</div>
<section class="panel" aria-labelledby="list-title">
<div class="panel__bar">
<h2 id="list-title">Markers</h2>
<button type="button" id="clear" class="btn">Clear all</button>
</div>
<label class="field" for="label-input">
<span>Label for next marker</span>
<input id="label-input" type="text" value="Site" maxlength="24" />
</label>
<ul id="list" class="list"></ul>
</section>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgWorldMarker() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>SVG World Marker</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 Marker
Drop and select labeled markers on a pure SVG map with pointer and keyboard interactions.
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.