Choropleth Map
Standalone SVG choropleth — region polygons shaded by data value with tooltip, legend, and accessible data-table fallback.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/choropleth-map.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook4 stories available:
Code
"use client";
import {
type ComponentPropsWithoutRef,
createContext,
forwardRef,
type ReactNode,
useCallback,
useContext,
useId,
useMemo,
useState,
} from "react";
import { cn } from "../../lib/utils";
const VIEWBOX_WIDTH = 1000;
const VIEWBOX_HEIGHT = 500;
/**
* Geographic coordinate `[longitude, latitude]`.
*
* @public
*/
export type GeoPosition = [number, number];
/**
* A region polygon. Outer ring closes by repeating the first point;
* holes are out of scope for the MVP.
*
* @public
*/
export type ChoroplethRegion = {
/** Outer ring as `[lng, lat]` positions. */
coordinates: GeoPosition[];
/** Stable identifier — matches keys in the `data` map. */
id: string;
/** Human-readable region name shown in the default tooltip. */
name: string;
};
/**
* Two-stop color scale `[low, high]` for sequential data, or three stops
* `[low, mid, high]` for diverging data. Linear interpolation between stops.
*
* @public
*/
export type ChoroplethColorScale = [string, string, string] | [string, string];
/**
* Localizable strings.
*
* @public
*/
export type ChoroplethMapLabels = {
/** Aria-label for the SVG canvas. Defaults to `"Choropleth map"`. */
region?: string;
};
const DEFAULT_LABELS = {
region: "Choropleth map",
} as const satisfies Required<ChoroplethMapLabels>;
const DEFAULT_SCALE: ChoroplethColorScale = ["#f1f5f9", "#1d4ed8"];
const DEFAULT_MISSING = "#e5e7eb";
type Hover = { id: string; value?: number };
type ChoroplethCtx = {
colorFor: (value?: number) => string;
hover?: Hover;
legend?: { domain: [number, number]; scale: ChoroplethColorScale };
regionByid: Map<string, ChoroplethRegion>;
setHover: (next?: Hover) => void;
valueFor: (id: string) => null | number;
};
const ChoroplethContext = createContext<ChoroplethCtx | null>(null);
function useChoroplethContext(): ChoroplethCtx {
const ctx = useContext(ChoroplethContext);
if (!ctx) {
throw new Error("ChoroplethMap subcomponent used outside its root.");
}
return ctx;
}
function projectEquirectangular(
position: GeoPosition,
width: number,
height: number,
): { x: number; y: number } {
const [lng, lat] = position;
const x = ((lng + 180) / 360) * width;
const y = ((90 - lat) / 180) * height;
return { x, y };
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
function parseHex(color: string): [number, number, number] | undefined {
const match = /^#([\da-f]{6})$/i.exec(color.trim());
if (!match) return undefined;
const hex = match[1] ?? "";
return [
Number.parseInt(hex.slice(0, 2), 16),
Number.parseInt(hex.slice(2, 4), 16),
Number.parseInt(hex.slice(4, 6), 16),
];
}
function toHex(channel: number): string {
return clamp(Math.round(channel), 0, 255).toString(16).padStart(2, "0");
}
function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
function interpolateColor(stops: string[], t: number): string {
if (stops.length === 0) return "#000000";
if (stops.length === 1) return stops[0] ?? "#000000";
const segments = stops.length - 1;
const scaledT = clamp(t, 0, 1) * segments;
const segmentIndex = Math.min(Math.floor(scaledT), segments - 1);
const localT = scaledT - segmentIndex;
const lower = stops[segmentIndex];
const upper = stops[segmentIndex + 1];
if (!lower || !upper) return stops[0] ?? "#000000";
const lowerRgb = parseHex(lower);
const upperRgb = parseHex(upper);
if (!lowerRgb || !upperRgb) return lower;
const [lr, lg, lb] = lowerRgb;
const [ur, ug, ub] = upperRgb;
const r = lerp(lr, ur, localT);
const g = lerp(lg, ug, localT);
const b = lerp(lb, ub, localT);
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
function computeDomain(values: number[]): [number, number] {
const first = values[0];
if (first === undefined) return [0, 1];
const min = values.reduce(
(accumulator, value) => Math.min(accumulator, value),
first,
);
const max = values.reduce(
(accumulator, value) => Math.max(accumulator, value),
first,
);
if (min === max) return [min, max + 1];
return [min, max];
}
/**
* Props for {@link ChoroplethMap}.
*
* @public
*/
export type ChoroplethMapProps = {
/** Color stops. Two stops = sequential; three stops = diverging. Defaults to a blue ramp. */
colorScale?: ChoroplethColorScale;
/** Map of region id → numeric value. */
data: Record<string, number>;
/** Optional explicit `[min, max]` value domain. Defaults to the data extent. */
domain?: [number, number];
/** Localizable strings. */
labels?: ChoroplethMapLabels;
/** Color used when a region has no data. Defaults to a neutral gray. */
missingColor?: string;
/** Fires after a region click. */
onSelectRegion?: (region: ChoroplethRegion) => void;
/** Region polygons. */
regions: ChoroplethRegion[];
} & ComponentPropsWithoutRef<"section">;
type RegionPathProps = {
active: boolean;
onSelect: (region: ChoroplethRegion) => void;
region: ChoroplethRegion;
selectedId?: string;
setHoverFn: (next?: Hover) => void;
};
function regionPath(region: ChoroplethRegion): string {
return region.coordinates
.map((coord, index) => {
const projected = projectEquirectangular(
coord,
VIEWBOX_WIDTH,
VIEWBOX_HEIGHT,
);
return `${index === 0 ? "M" : "L"}${projected.x.toString()},${projected.y.toString()}`;
})
.join(" ");
}
function RegionPath({
active,
onSelect,
region,
selectedId,
setHoverFn,
}: RegionPathProps): ReactNode {
const { colorFor, valueFor } = useChoroplethContext();
const value = valueFor(region.id) ?? undefined;
const fill = colorFor(value);
const handleEnter = (): void => {
setHoverFn({ id: region.id, value });
};
const handleLeave = (): void => {
setHoverFn();
};
const handleSelect = (): void => {
onSelect(region);
};
return (
<path
aria-label={`${region.name}${value === undefined ? " no data" : ` ${value.toString()}`}`}
className={cn(
"cursor-pointer outline-none transition-[opacity,filter]",
active ? "opacity-100" : "opacity-90 hover:opacity-100",
selectedId === region.id ? "stroke-foreground" : "stroke-background",
)}
d={regionPath(region) + " Z"}
data-region-id={region.id}
data-selected={selectedId === region.id ? "true" : undefined}
data-value={value}
fill={fill}
onBlur={handleLeave}
onClick={handleSelect}
onFocus={handleEnter}
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
strokeWidth={selectedId === region.id ? 2 : 0.75}
tabIndex={0}
/>
);
}
type ChoroplethTooltipRender = (arguments_: {
region: ChoroplethRegion;
value?: number;
}) => ReactNode;
/**
* Tooltip slot. Pass a render-prop function via `children` for full
* control, or omit it to use the default `Region Name · value` layout.
*
* @public
*/
export type ChoroplethTooltipProps = {
/** Render-prop receiving the hovered region + value. */
children?: ChoroplethTooltipRender;
} & Omit<ComponentPropsWithoutRef<"div">, "children">;
export const ChoroplethTooltip = forwardRef<
HTMLDivElement,
ChoroplethTooltipProps
>(({ children, className, ...rest }, ref) => {
const { hover, regionByid } = useChoroplethContext();
if (!hover) return null;
const region = regionByid.get(hover.id);
if (!region) return null;
return (
<div
className={cn(
"pointer-events-none absolute left-3 top-3 z-10 max-w-xs rounded-md border bg-popover px-2 py-1 text-xs text-popover-foreground shadow-md",
className,
)}
data-tooltip-region-id={region.id}
ref={ref}
role="status"
{...rest}
>
{children ? (
children({ region, value: hover.value })
) : (
<span>
<span className="font-medium">{region.name}</span>
{hover.value === undefined ? (
<span className="text-muted-foreground"> · no data</span>
) : (
<span> · {hover.value.toLocaleString()}</span>
)}
</span>
)}
</div>
);
});
ChoroplethTooltip.displayName = "ChoroplethTooltip";
/**
* Legend slot. Renders a horizontal color ramp with min / max labels.
*
* @public
*/
export type ChoroplethLegendProps = {
/** Optional title rendered above the ramp. */
title?: ReactNode;
} & Omit<ComponentPropsWithoutRef<"div">, "children">;
export const ChoroplethLegend = forwardRef<
HTMLDivElement,
ChoroplethLegendProps
>(({ className, title, ...rest }, ref) => {
const { legend } = useChoroplethContext();
if (!legend) return null;
const stops = legend.scale.join(", ");
return (
<div
className={cn(
"absolute bottom-3 right-3 z-10 flex flex-col gap-1 rounded-md border bg-background/95 px-2 py-1 text-[11px] text-foreground shadow-sm backdrop-blur",
className,
)}
data-legend
ref={ref}
{...rest}
>
{title ? (
<span className="font-medium uppercase tracking-wide text-muted-foreground">
{title}
</span>
) : null}
<div
aria-hidden="true"
className="h-2 w-32 rounded-full"
style={{ background: `linear-gradient(to right, ${stops})` }}
/>
<div className="flex justify-between text-muted-foreground">
<span>{legend.domain[0].toLocaleString()}</span>
<span>{legend.domain[1].toLocaleString()}</span>
</div>
</div>
);
});
ChoroplethLegend.displayName = "ChoroplethLegend";
type DataSummaryProps = {
data: Record<string, number>;
regions: ChoroplethRegion[];
titleId: string;
};
function DataSummary({ data, regions, titleId }: DataSummaryProps): ReactNode {
return (
<div aria-labelledby={titleId} className="sr-only" role="region">
<h3 id={titleId}>Choropleth data summary</h3>
<table>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Value</th>
</tr>
</thead>
<tbody>
{regions.map((region) => {
const value = data[region.id];
return (
<tr key={region.id}>
<td>{region.name}</td>
<td>
{value === undefined ? "no data" : value.toLocaleString()}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
type ChildBuckets = {
legend: ReactNode;
tooltip: ReactNode;
};
function bucketChildren(children: ReactNode): ChildBuckets {
const list: ReactNode[] = Array.isArray(children) ? children : [children];
return list.reduce<ChildBuckets>(
(accumulator, child) => {
const name = displayName(child);
if (name === ChoroplethLegend.displayName) accumulator.legend = child;
else if (name === ChoroplethTooltip.displayName)
accumulator.tooltip = child;
return accumulator;
},
{ legend: null, tooltip: null },
);
}
function displayName(child: ReactNode): string | undefined {
if (typeof child !== "object" || child === null) return undefined;
if (!("type" in child)) return undefined;
const type = (child as { type: unknown }).type;
if (typeof type !== "object" && typeof type !== "function") return undefined;
const name = (type as { displayName?: unknown }).displayName;
return typeof name === "string" ? name : undefined;
}
function useChoroplethState(arguments_: {
colorScale: ChoroplethColorScale;
data: Record<string, number>;
domain: [number, number];
missingColor: string;
regions: ChoroplethRegion[];
}): ChoroplethCtx {
const { colorScale, data, domain, missingColor, regions } = arguments_;
const regionByid = useMemo(
() =>
new Map<string, ChoroplethRegion>(
regions.map((region) => [region.id, region]),
),
[regions],
);
const valueFor = useCallback(
(id: string): null | number => data[id] ?? null,
[data],
);
const colorFor = useCallback(
(value?: number): string => {
if (value === undefined) return missingColor;
const [min, max] = domain;
const span = max - min;
const t = span === 0 ? 0.5 : (value - min) / span;
return interpolateColor(colorScale, t);
},
[colorScale, domain, missingColor],
);
const [hover, setHover] = useState<Hover | undefined>();
return useMemo<ChoroplethCtx>(
() => ({
colorFor,
hover,
legend: { domain, scale: colorScale },
regionByid,
setHover,
valueFor,
}),
[colorFor, colorScale, domain, hover, regionByid, valueFor],
);
}
type RegionsLayerProps = {
onSelect: (region: ChoroplethRegion) => void;
regions: ChoroplethRegion[];
selectedId?: string;
setHoverFn: (next?: Hover) => void;
};
function RegionsLayer({
onSelect,
regions,
selectedId,
setHoverFn,
}: RegionsLayerProps): ReactNode {
return (
<g>
{regions.map((region) => (
<RegionPath
active={selectedId === region.id}
key={region.id}
onSelect={onSelect}
region={region}
selectedId={selectedId}
setHoverFn={setHoverFn}
/>
))}
</g>
);
}
/**
* Region-colored data map (choropleth). Standalone SVG primitive — no
* external map library or tile provider required. Pass an array of
* {@link ChoroplethRegion} polygons, a `data` map (region id → numeric
* value), and an optional `colorScale`. Hover any region to surface the
* tooltip; click to fire `onSelectRegion`.
*
* Compose with {@link ChoroplethLegend} (color ramp + min / max labels)
* and {@link ChoroplethTooltip} (custom render-prop).
*
* @example
* ```tsx
* <ChoroplethMap
* regions={countries}
* data={{ FR: 2937, DE: 4082, IT: 2107 }}
* colorScale={["#f1f5f9", "#1d4ed8"]}
* >
* <ChoroplethTooltip />
* <ChoroplethLegend title="GDP (B USD)" />
* </ChoroplethMap>
* ```
*
* @public
*/
export const ChoroplethMap = forwardRef<HTMLElement, ChoroplethMapProps>(
(props, ref) => {
const {
children,
className,
colorScale = DEFAULT_SCALE,
data,
domain: domainProperty,
labels,
missingColor = DEFAULT_MISSING,
onSelectRegion,
regions,
...rest
} = props;
const titleId = useId();
const resolvedLabels = useMemo(
() => ({ ...DEFAULT_LABELS, ...labels }),
[labels],
);
const domain = useMemo(
() => domainProperty ?? computeDomain(Object.values(data)),
[data, domainProperty],
);
const ctx = useChoroplethState({
colorScale,
data,
domain,
missingColor,
regions,
});
const buckets = useMemo(() => bucketChildren(children), [children]);
const [selectedId, setSelectedId] = useState<string | undefined>();
const handleSelect = useCallback(
(region: ChoroplethRegion) => {
setSelectedId(region.id);
onSelectRegion?.(region);
},
[onSelectRegion],
);
return (
<ChoroplethContext.Provider value={ctx}>
<section
aria-label={resolvedLabels.region}
className={cn(
"relative aspect-[2/1] w-full overflow-hidden rounded-2xl border bg-background text-foreground",
className,
)}
ref={ref}
{...rest}
>
<svg
aria-hidden="true"
className="block h-full w-full"
preserveAspectRatio="xMidYMid meet"
viewBox={`0 0 ${VIEWBOX_WIDTH.toString()} ${VIEWBOX_HEIGHT.toString()}`}
>
<RegionsLayer
onSelect={handleSelect}
regions={regions}
selectedId={selectedId}
setHoverFn={ctx.setHover}
/>
</svg>
{buckets.tooltip}
{buckets.legend}
<DataSummary data={data} regions={regions} titleId={titleId} />
</section>
</ChoroplethContext.Provider>
);
},
);
ChoroplethMap.displayName = "ChoroplethMap";
typescript