Choropleth

Regions coloured by a data value, with outlines and hover highlighting.

Usage

<Choropleth> builds on <Layer>: give it polygons, the property to colour by, and a ramp. It renders the fill, the borders, and the hover highlight for you.

ZIP area · km²
<7
7-12
12-17
17+
Hover a ZIP
40 central Denver ZIPs
"use client";

import type { GeoJSONFeature } from "mapbox-gl";
import { useState } from "react";

import { Choropleth, type ChoroplethRamp } from "@/components/ui/choropleth";
import { Legend } from "@/components/ui/legend";
import { Map } from "@/components/ui/map";

// One ramp object, shared by the map and the legend - they cannot drift.
const ramp = {
  kind: "step",
  breaks: [7, 12, 17],
  colors: ["#eef2f6", "#ccd6e0", "#9fb1c2", "#6d859c"],
} satisfies ChoroplethRamp;

export function ChoroplethExample() {
  const [feature, setFeature] = useState<GeoJSONFeature | null>(null);
  const area = feature ? Number(feature.properties?.area) : null;

  return (
    <div className="relative h-[460px] w-full">
      <Map center={[-104.996, 39.737]} zoom={9.9}>
        <Choropleth
          id="zips"
          data="/data/denver-zips.geojson"
          value="area"
          ramp={ramp}
          featureId="zip"
          opacity={0.55}
          onHover={setFeature}
        />
      </Map>

      <Legend
        ramp={ramp}
        title="ZIP area · km²"
        activeValue={area}
        className="absolute top-3 left-3 z-10"
      />

      <div className="bg-background/85 absolute bottom-3 left-3 z-10 rounded-md border px-2.5 py-1 text-xs backdrop-blur">
        {feature ? (
          <span>
            <span className="font-mono font-semibold">
              {String(feature.properties?.zip)}
            </span>
            <span className="text-muted-foreground">
              {" "}
              · {area?.toFixed(1)} km²
            </span>
          </span>
        ) : (
          <span className="text-muted-foreground">Hover a ZIP</span>
        )}
      </div>

      <div className="bg-background/85 text-muted-foreground absolute right-3 bottom-3 z-10 rounded-md border px-2 py-1 text-[11px] backdrop-blur">
        40 central Denver ZIPs
      </div>
    </div>
  );
}

Ramps

A ramp is a plain object, not an expression. That matters: the same object drives the paint expression and <Legend>, so the swatches can never drift from the colours actually on the map.

const ramp: ChoroplethRamp = {
  kind: "step",
  breaks: [7, 12, 17],
  colors: ["#eef2f6", "#ccd6e0", "#9fb1c2", "#6d859c"],
};

Three kinds are supported:

{ kind: "step", breaks: [10, 20], colors: ["#eee", "#88f", "#00a"] }
{ kind: "linear", stops: [[0, "#eee"], [100, "#00a"]] }
{ kind: "categorical", values: [["a", "#f00"], ["b", "#0f0"]], fallback: "#ccc" }
Prefer step for skewed data. Real world values are rarely evenly spread - a linear ramp over areas or populations collapses most features into one shade. Quantile breaks with step keep the map readable.

Hover

Hover highlighting uses Mapbox feature-state, which needs a stable feature id. GeoJSON features rarely carry one, so name the property that holds it with featureId:

<Choropleth id="zips" data={zips} value="area" ramp={ramp} featureId="zip" />

This is set as promoteId on the source. The highlight is then pure GPU state - no re-render, no setData, nothing recomputed per frame. Omit featureId and highlighting turns off, though onHover and onSelect still fire.

Props

PropTypeDefaultDescription
idstringNames the source and the layers derived from it.
dataGeoJSON | stringGeoJSON object or a URL.
valuestring | ExpressionProperty name to colour by, or a full Mapbox expression.
rampChoroplethRampColour scale: step, linear or categorical. Share it with Legend.
featureIdstringProperty holding a stable id. Required for hover highlighting.
opacitynumber0.75Fill opacity. Hovered features get a slight bump.
outlineboolean | { color?, width? }trueBorders between features.
hoverbooleantrueHighlight the feature under the cursor.
onHover / onSelect(feature, event) => voidFired on mousemove and click. onHover receives null on leave.