UI Components Medium
SVG Choropleth Fill
Click regions to rotate through data values and update a clear legend without a tile server.
Open in Lab
MCP
html css javascript react
Targets: TS JS HTML React
Code
:root {
--bg: #0b1020;
--surface: #121a2d;
--line: #263555;
--text: #eef2ff;
--muted: #9eb1d4;
}
*, *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(900px 560px at 18% -12%, #17233d, var(--bg));
color: var(--text);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
padding: clamp(1rem, 4vw, 3rem);
}
.demo {
width: min(920px, 100%);
margin: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 22px;
padding: clamp(1rem, 3vw, 2rem);
box-shadow: 0 20px 70px #0006;
}
.head h1 { margin: 0 0 .4rem; font-size: 1.35rem; letter-spacing: -0.01em; }
.head p { margin: 0 0 1.4rem; color: var(--muted); font-size: .87rem; max-width: 62ch; }
kbd {
background: #1b2540;
border: 1px solid var(--line);
border-bottom-width: 2px;
border-radius: 5px;
padding: 1px 5px;
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
}
.board {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(220px, 1fr);
gap: 1.25rem;
align-items: start;
}
@media (max-width: 680px) {
.board { grid-template-columns: 1fr; }
}
.map {
width: 100%;
height: auto;
background: #0d1527;
border: 1px solid var(--line);
border-radius: 16px;
padding: 8px;
}
.region {
stroke: #0b1020;
stroke-width: 1.5;
cursor: pointer;
transition: fill 280ms ease, transform 160ms ease;
transform-box: fill-box;
transform-origin: center;
}
.region:hover { transform: scale(1.035); }
.region:focus-visible {
outline: none;
stroke: #b8d8ff;
stroke-width: 2.5;
}
.region[data-selected="true"] {
stroke: #b8d8ff;
stroke-width: 2.5;
}
.region-label {
fill: #dce7ff;
font-size: 9px;
font-weight: 600;
text-anchor: middle;
pointer-events: none;
paint-order: stroke;
stroke: rgba(4, 8, 18, .6);
stroke-width: 2.5px;
}
.panel {
display: grid;
gap: .9rem;
background: #18233b;
border: 1px solid var(--line);
border-radius: 16px;
padding: 1rem;
}
.readout { display: grid; gap: 2px; }
.readout-label { font-size: .68rem; text-transform: uppercase; letter-spacing: .09em; color: var(--muted); }
.readout-name { font-size: 1.05rem; }
.readout-value { font-size: .8rem; color: var(--muted); }
.legend { display: grid; gap: .3rem; }
.legend-item {
display: flex;
align-items: center;
gap: .55rem;
font-size: .78rem;
color: var(--muted);
padding: .2rem .35rem;
border-radius: 8px;
transition: background 180ms ease, color 180ms ease;
}
.legend-item[data-active="true"] { background: #22304f; color: var(--text); }
.swatch {
width: 28px;
height: 12px;
border-radius: 3px;
flex: none;
box-shadow: inset 0 0 0 1px #ffffff14;
}
.legend-count { margin-left: auto; font-variant-numeric: tabular-nums; }
.reset {
appearance: none;
background: #22304f;
color: var(--text);
border: 1px solid var(--line);
border-radius: 10px;
padding: .55rem .75rem;
font: inherit;
font-size: .82rem;
cursor: pointer;
transition: background 160ms ease;
}
.reset:hover { background: #2c3d63; }
@media (prefers-reduced-motion: reduce) {
.region, .legend-item, .reset { transition: none; }
.region:hover { transform: none; }
}/* SVG Choropleth Fill — data-driven fills, keyboard-operable regions, live legend. */
(() => {
const SVG_NS = "http://www.w3.org/2000/svg";
// Ordinal scale: index 0 = "no data", 1..5 = binned values.
const SCALE = [
{ label: "No data", color: "#16202c", range: "—" },
{ label: "Very low", color: "#1d3f5c", range: "0–19" },
{ label: "Low", color: "#20618a", range: "20–39" },
{ label: "Moderate", color: "#2f8fa0", range: "40–59" },
{ label: "High", color: "#43ad82", range: "60–79" },
{ label: "Very high", color: "#9fd356", range: "80–100" },
];
// Abstract regions (no geographic claims). points = polygon in viewBox units.
const REGIONS = [
{ id: "nw", name: "Northwest", points: "16,20 108,20 96,86 16,78", bin: 2 },
{ id: "nc", name: "North Central", points: "112,20 210,20 204,90 100,88", bin: 4 },
{ id: "ne", name: "Northeast", points: "214,20 304,26 300,96 208,92", bin: 1 },
{ id: "cw", name: "Central West", points: "16,82 94,90 88,152 18,146", bin: 3 },
{ id: "cc", name: "Midlands", points: "98,92 202,94 198,158 90,154", bin: 5 },
{ id: "ce", name: "Central East", points: "206,98 300,100 296,162 200,160", bin: 0 },
{ id: "sw", name: "Southwest", points: "20,150 86,156 78,220 24,214", bin: 1 },
{ id: "sc", name: "South Central", points: "92,158 196,162 190,222 82,218", bin: 3 },
{ id: "se", name: "Southeast", points: "202,164 296,166 292,224 194,222", bin: 4 },
];
const gRegions = document.getElementById("regions");
const legendEl = document.getElementById("legend");
const selName = document.getElementById("selName");
const selValue = document.getElementById("selValue");
const resetBtn = document.getElementById("reset");
if (!gRegions || !legendEl) return;
const state = new Map(REGIONS.map((r) => [r.id, r.bin]));
const nodes = new Map();
let selected = null;
const centroid = (points) => {
const pts = points.trim().split(/\s+/).map((p) => p.split(",").map(Number));
const sx = pts.reduce((a, p) => a + p[0], 0);
const sy = pts.reduce((a, p) => a + p[1], 0);
return [sx / pts.length, sy / pts.length];
};
const describe = (r) => {
const s = SCALE[state.get(r.id)];
return `${r.name}: ${s.label}${s.range === "—" ? "" : ` (${s.range})`}`;
};
function paint(id) {
const r = REGIONS.find((x) => x.id === id);
const node = nodes.get(id);
const bin = state.get(id);
node.setAttribute("fill", SCALE[bin].color);
node.setAttribute("aria-label", describe(r));
node.setAttribute("aria-valuenow", String(bin));
node.setAttribute("data-bin", String(bin));
}
function select(id) {
selected = id;
for (const [rid, node] of nodes) node.dataset.selected = String(rid === id);
const r = REGIONS.find((x) => x.id === id);
const s = SCALE[state.get(id)];
selName.textContent = r.name;
selValue.textContent = `${s.label}${s.range === "—" ? "" : ` · ${s.range}`}`;
renderLegend();
}
function step(id, delta) {
const next = (state.get(id) + delta + SCALE.length) % SCALE.length;
state.set(id, next);
paint(id);
select(id);
}
function renderLegend() {
const counts = SCALE.map(() => 0);
for (const bin of state.values()) counts[bin] += 1;
const activeBin = selected ? state.get(selected) : -1;
legendEl.textContent = "";
SCALE.forEach((s, i) => {
const row = document.createElement("div");
row.className = "legend-item";
row.setAttribute("role", "listitem");
row.dataset.active = String(i === activeBin);
const sw = document.createElement("span");
sw.className = "swatch";
sw.style.background = s.color;
const label = document.createElement("span");
label.textContent = s.range === "—" ? s.label : `${s.label} · ${s.range}`;
const count = document.createElement("span");
count.className = "legend-count";
count.textContent = `${counts[i]}`;
row.append(sw, label, count);
legendEl.append(row);
});
}
REGIONS.forEach((r, i) => {
const poly = document.createElementNS(SVG_NS, "polygon");
poly.setAttribute("points", r.points);
poly.setAttribute("class", "region");
poly.setAttribute("tabindex", "0");
poly.setAttribute("role", "slider");
poly.setAttribute("aria-valuemin", "0");
poly.setAttribute("aria-valuemax", String(SCALE.length - 1));
poly.dataset.id = r.id;
poly.addEventListener("click", () => step(r.id, 1));
poly.addEventListener("focus", () => select(r.id));
poly.addEventListener("keydown", (e) => {
if (e.key === "Enter" || e.key === " " || e.key === "ArrowUp" || e.key === "ArrowRight") {
e.preventDefault();
step(r.id, 1);
} else if (e.key === "ArrowDown" || e.key === "ArrowLeft") {
e.preventDefault();
step(r.id, -1);
} else if (e.key === "Home") {
e.preventDefault();
state.set(r.id, 0);
paint(r.id);
select(r.id);
}
});
gRegions.append(poly);
nodes.set(r.id, poly);
const [cx, cy] = centroid(r.points);
const text = document.createElementNS(SVG_NS, "text");
text.setAttribute("class", "region-label");
text.setAttribute("x", String(cx));
text.setAttribute("y", String(cy + 3));
text.textContent = r.name.length > 13 ? r.name.split(" ").pop() : r.name;
gRegions.append(text);
void i;
});
resetBtn?.addEventListener("click", () => {
for (const r of REGIONS) {
state.set(r.id, 0);
paint(r.id);
}
selected = null;
for (const node of nodes.values()) node.dataset.selected = "false";
selName.textContent = "None";
selValue.textContent = "All regions cleared";
renderLegend();
});
REGIONS.forEach((r) => paint(r.id));
renderLegend();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SVG Choropleth Fill</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="demo" data-demo="SVG Choropleth Fill">
<header class="head">
<h1>Regional adoption index</h1>
<p>
Click a region, or focus it and press <kbd>Enter</kbd> / <kbd>↑</kbd> <kbd>↓</kbd>
to rotate its value through the scale. The legend counts update live.
</p>
</header>
<div class="board">
<svg class="map" viewBox="0 0 320 240" role="group" aria-label="Abstract regional map">
<g id="regions"></g>
</svg>
<aside class="panel">
<div class="readout" aria-live="polite">
<span class="readout-label">Selected region</span>
<strong class="readout-name" id="selName">None</strong>
<span class="readout-value" id="selValue">Pick a region to inspect it</span>
</div>
<div class="legend" id="legend" role="list" aria-label="Value scale and region counts"></div>
<button class="reset" type="button" id="reset">Reset all regions</button>
</aside>
</div>
</main>
<script src="script.js"></script>
</body>
</html>import { useState } from "react";
export function SvgChoroplethFill() {
const [items, setItems] = useState(["Alpha", "Beta", "Gamma"]);
return (
<section className="demo">
<h2>SVG Choropleth Fill</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 Choropleth Fill
Click regions to rotate through data values and update a clear legend without a tile server.
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.