UI Components Medium
SVG Map Tooltip
Hover SVG regions and mirror their metadata into a keyboard-friendly tooltip.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
*,*::before,*::after { box-sizing: border-box; }
:root {
--bg: #0b1020;
--panel: #121a2d;
--line: #263555;
--text: #eef2ff;
--muted: #a8b5d1;
--healthy: #3ddc97;
--watch: #ffc75f;
--risk: #ff6b81;
}
body {
margin: 0;
min-height: 100vh;
padding: clamp(1rem, 4vw, 3rem);
background: radial-gradient(120% 90% at 20% 0%, #16203a 0%, var(--bg) 60%);
color: var(--text);
font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
}
.demo {
width: min(760px, 100%);
margin: auto;
}
.demo__head h1 { margin: 0 0 .35rem; font-size: 1.4rem; letter-spacing: -.02em; }
.demo__head p { margin: 0 0 1.25rem; font-size: .86rem; }
.muted { color: var(--muted); }
.map-wrap {
position: relative;
background: var(--panel);
border: 1px solid var(--line);
border-radius: 20px;
padding: 1.1rem;
box-shadow: 0 24px 70px -30px #000;
}
.map { display: block; width: 100%; height: auto; overflow: visible; }
.region {
fill: #1e2740;
stroke: #38445f;
stroke-width: 1.5;
stroke-linejoin: round;
cursor: pointer;
outline: none;
transform-box: fill-box;
transform-origin: center;
transition: fill 160ms ease, stroke 160ms ease, transform 160ms ease, filter 160ms ease;
}
.region[data-status="healthy"]:hover,
.region[data-status="healthy"].is-active { fill: #17513f; stroke: var(--healthy); }
.region[data-status="watch"]:hover,
.region[data-status="watch"].is-active { fill: #4e3f1c; stroke: var(--watch); }
.region[data-status="risk"]:hover,
.region[data-status="risk"].is-active { fill: #55222c; stroke: var(--risk); }
.region.is-active {
transform: scale(1.02);
filter: drop-shadow(0 0 12px rgba(120, 169, 255, .28));
}
.region:focus-visible { stroke: #9db4ff; stroke-width: 2.75; }
.tip {
position: absolute;
z-index: 5;
left: 0;
top: 0;
min-width: 178px;
padding: .65rem .75rem;
background: #0d121e;
border: 1px solid var(--line);
border-radius: 12px;
box-shadow: 0 18px 44px -18px #000;
pointer-events: none;
opacity: 0;
transition: opacity 130ms ease;
}
.tip[data-shown] { opacity: 1; }
.tip::after {
content: "";
position: absolute;
left: var(--arrow, 50%);
bottom: -6px;
width: 10px; height: 10px;
margin-left: -5px;
background: #0d121e;
border-right: 1px solid var(--line);
border-bottom: 1px solid var(--line);
transform: rotate(45deg);
}
.tip[data-flip]::after { bottom: auto; top: -6px; transform: rotate(225deg); }
.tip__title { margin: 0; font-size: .9rem; display: flex; align-items: center; gap: .5rem; }
.tip__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); flex: none; }
.tip__dot[data-status="healthy"] { background: var(--healthy); }
.tip__dot[data-status="watch"] { background: var(--watch); }
.tip__dot[data-status="risk"] { background: var(--risk); }
.tip__stats { display: flex; gap: 1.15rem; margin: .55rem 0 0; }
.tip__stats > div { margin: 0; }
.tip__stats dt {
margin: 0;
font-size: .62rem;
letter-spacing: .09em;
text-transform: uppercase;
color: var(--muted);
}
.tip__stats dd { margin: .15rem 0 0; font-size: .9rem; font-variant-numeric: tabular-nums; }
.sr-live {
position: absolute;
width: 1px; height: 1px;
margin: -1px; padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
.region, .tip { transition: none; }
.region.is-active { transform: none; }
}/* SVG Map Tooltip — pointer + keyboard driven, viewport-clamped positioning. */
(() => {
const wrap = document.querySelector(".map-wrap");
const tip = document.querySelector("#map-tip");
const live = document.querySelector("[data-live]");
const regions = Array.from(document.querySelectorAll(".region"));
if (!wrap || !tip || !regions.length) return;
const els = {
dot: tip.querySelector("[data-tip-dot]"),
name: tip.querySelector("[data-tip-name]"),
users: tip.querySelector("[data-tip-users]"),
growth: tip.querySelector("[data-tip-growth]"),
};
let current = null;
let raf = 0;
/** Anchor point in .map-wrap coordinates: either the pointer or the region centroid. */
function anchorFor(region, event) {
const wrapBox = wrap.getBoundingClientRect();
if (event && event.clientX !== undefined && event.pointerType !== "") {
return { x: event.clientX - wrapBox.left, y: event.clientY - wrapBox.top };
}
const box = region.getBoundingClientRect();
return {
x: box.left + box.width / 2 - wrapBox.left,
y: box.top + box.height / 2 - wrapBox.top,
};
}
function place(anchor) {
const tipBox = tip.getBoundingClientRect();
const maxX = wrap.clientWidth - tipBox.width - 8;
const idealLeft = anchor.x - tipBox.width / 2;
const left = Math.max(8, Math.min(idealLeft, maxX));
// Flip below the anchor when there is no headroom above it.
const gap = 14;
let top = anchor.y - tipBox.height - gap;
const flipped = top < 8;
if (flipped) top = anchor.y + gap;
tip.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`;
tip.style.setProperty("--arrow", `${Math.round(anchor.x - left)}px`);
tip.toggleAttribute("data-flip", flipped);
}
function show(region, event) {
if (current && current !== region) current.classList.remove("is-active");
current = region;
region.classList.add("is-active");
const d = region.dataset;
els.name.textContent = d.name;
els.users.textContent = d.users;
els.growth.textContent = d.growth;
els.growth.style.color =
d.status === "risk" ? "#ff6b81" : d.status === "watch" ? "#ffc75f" : "#3ddc97";
els.dot.dataset.status = d.status;
tip.hidden = false;
tip.setAttribute("data-shown", "");
region.setAttribute("aria-describedby", "map-tip");
const anchor = anchorFor(region, event);
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => place(anchor));
if (live) live.textContent = `${d.name}: ${d.users} users, ${d.growth} growth, ${d.status}.`;
}
function hide() {
if (current) {
current.classList.remove("is-active");
current.removeAttribute("aria-describedby");
current = null;
}
tip.removeAttribute("data-shown");
tip.removeAttribute("data-flip");
tip.hidden = true;
if (live) live.textContent = "";
}
regions.forEach((region, i) => {
region.addEventListener("pointerenter", (e) => show(region, e));
region.addEventListener("pointermove", (e) => {
if (current !== region) return;
const anchor = anchorFor(region, e);
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => place(anchor));
});
region.addEventListener("pointerleave", () => {
if (region !== document.activeElement) hide();
});
region.addEventListener("focus", () => show(region, null));
region.addEventListener("blur", hide);
region.addEventListener("keydown", (e) => {
const step =
e.key === "ArrowRight" || e.key === "ArrowDown"
? 1
: e.key === "ArrowLeft" || e.key === "ArrowUp"
? -1
: 0;
if (step) {
e.preventDefault();
regions[(i + step + regions.length) % regions.length].focus();
} else if (e.key === "Escape") {
hide();
region.blur();
} else if (e.key === "Home") {
e.preventDefault();
regions[0].focus();
} else if (e.key === "End") {
e.preventDefault();
regions[regions.length - 1].focus();
}
});
});
// Keep an open (keyboard-anchored) tooltip correct on resize/scroll.
const reflow = () => {
if (current) place(anchorFor(current, null));
};
window.addEventListener("resize", reflow);
window.addEventListener("scroll", reflow, { passive: true });
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG Map Tooltip</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="SVG Map Tooltip">
<header class="demo__head">
<h1>Regional coverage</h1>
<p class="muted">
Hover or focus a region. Arrow keys move between regions, Escape dismisses the tooltip.
</p>
</header>
<div class="map-wrap">
<svg class="map" viewBox="0 0 400 260" role="group" aria-label="Abstract regional map with six regions" aria-describedby="map-tip">
<g class="regions">
<path class="region" tabindex="0" role="button"
data-name="North Ridge" data-users="18,420" data-growth="+12.4%" data-status="healthy"
aria-label="North Ridge. 18,420 users. Growth plus 12.4 percent. Healthy."
d="M40 30 L170 20 L200 90 L120 120 L45 100 Z" />
<path class="region" tabindex="0" role="button"
data-name="East Harbor" data-users="11,905" data-growth="+4.1%" data-status="healthy"
aria-label="East Harbor. 11,905 users. Growth plus 4.1 percent. Healthy."
d="M170 20 L350 35 L360 110 L200 90 Z" />
<path class="region" tabindex="0" role="button"
data-name="Central Basin" data-users="24,077" data-growth="-1.8%" data-status="watch"
aria-label="Central Basin. 24,077 users. Growth minus 1.8 percent. Watch."
d="M120 120 L200 90 L360 110 L300 175 L150 185 Z" />
<path class="region" tabindex="0" role="button"
data-name="West Flats" data-users="7,344" data-growth="+0.6%" data-status="watch"
aria-label="West Flats. 7,344 users. Growth plus 0.6 percent. Watch."
d="M45 100 L120 120 L150 185 L60 200 Z" />
<path class="region" tabindex="0" role="button"
data-name="South Delta" data-users="9,610" data-growth="-6.2%" data-status="risk"
aria-label="South Delta. 9,610 users. Growth minus 6.2 percent. At risk."
d="M60 200 L150 185 L300 175 L280 240 L90 245 Z" />
<path class="region" tabindex="0" role="button"
data-name="Verde Point" data-users="3,188" data-growth="+21.9%" data-status="healthy"
aria-label="Verde Point. 3,188 users. Growth plus 21.9 percent. Healthy."
d="M300 175 L360 110 L385 190 L330 225 Z" />
</g>
</svg>
<div class="tip" id="map-tip" role="tooltip" hidden>
<p class="tip__title">
<span class="tip__dot" data-tip-dot></span>
<strong data-tip-name></strong>
</p>
<dl class="tip__stats">
<div><dt>Users</dt><dd data-tip-users></dd></div>
<div><dt>Growth</dt><dd data-tip-growth></dd></div>
</dl>
</div>
</div>
<p class="sr-live" role="status" aria-live="polite" data-live></p>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgMapTooltip() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>SVG Map Tooltip</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 Map Tooltip
Hover SVG regions and mirror their metadata into a keyboard-friendly tooltip.
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.