Style GeoJSON in Leaflet: Choropleth, Popups and Legend
Leaflet styles GeoJSON through a function that runs once per feature, so every polygon can take its color from its own data. This example shades the EU member states by GDP per capita, with boundaries from Eurostat GISCO and figures from the Eurostat statistics API. Hover a country to highlight it, click it for the value, and read the classes off the legend.
const BOUNDARIES = "https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_20M_2021_4326_LEVL_0.geojson";
const STATISTICS = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/nama_10r_2gdp?format=JSON&lang=EN&unit=EUR_HAB&time=2022";
const STOPS = [
{ from: 70000, color: "#99000d", label: "€70,000 and above" },
{ from: 45000, color: "#e34a33", label: "€45,000 to €70,000" },
{ from: 30000, color: "#fc8d59", label: "€30,000 to €45,000" },
{ from: 20000, color: "#fdcc8a", label: "€20,000 to €30,000" },
{ from: 0, color: "#fef0d9", label: "under €20,000" },
];
function colorFor(value) {
if (value === null) return "#e0e0e0";
return STOPS.find((stop) => value >= stop.from).color;
}
const map = L.map("map").setView([52, 10], 4);
L.tileLayer("https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=YOUR_API_KEY", {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
// JSON-stat: `value` is keyed by position, and the geo dimension maps each code to a position.
function toLookup(jsonStat) {
const index = jsonStat.dimension.geo.category.index;
const lookup = {};
for (const [code, position] of Object.entries(index)) {
const value = jsonStat.value[position];
if (value !== undefined && value !== null) lookup[code] = value;
}
return lookup;
}
Promise.all([
fetch(BOUNDARIES).then((response) => response.json()),
fetch(STATISTICS).then((response) => response.json()),
]).then(([boundaries, statistics]) => {
const gdp = toLookup(statistics);
const states = boundaries.features.filter((f) => f.properties.EU_STAT === "T");
const layer = L.geoJSON(states, {
// Called once per feature: the returned options style that feature.
style: (feature) => ({
fillColor: colorFor(gdp[feature.properties.NUTS_ID] ?? null),
fillOpacity: 0.9,
color: "#ffffff",
weight: 1,
}),
onEachFeature: (feature, featureLayer) => {
const value = gdp[feature.properties.NUTS_ID];
featureLayer.bindPopup(
"<strong>" + feature.properties.NAME_ENGL + "</strong><br>" +
(value ? "€" + value.toLocaleString("en") + " per capita" : "No data")
);
featureLayer.on({
mouseover: () => featureLayer.setStyle({ weight: 3, color: "#333333" }),
mouseout: () => layer.resetStyle(featureLayer),
});
},
attribution: "© <a href='https://ec.europa.eu/eurostat' target='_blank'>Eurostat</a>, © EuroGeographics",
}).addTo(map);
});
// A legend is a Leaflet control whose onAdd returns the element to show.
const legend = L.control({ position: "bottomleft" });
legend.onAdd = () => {
const div = L.DomUtil.create("div", "legend");
div.innerHTML =
"<strong>GDP per capita, 2022</strong><br>" +
STOPS.map((stop) => '<i style="background:' + stop.color + '"></i>' + stop.label).join("<br>") +
'<br><i style="background:#e0e0e0"></i>No data';
return div;
};
legend.addTo(map);<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://cdn.jsdelivr.net/npm/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
.legend { background: #fff; padding: 8px 10px; border-radius: 4px; font: 12px/1.6 sans-serif; box-shadow: 0 1px 4px rgba(0,0,0,0.3); }
.legend i { display: inline-block; width: 14px; height: 14px; margin-right: 6px; vertical-align: -2px; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const BOUNDARIES = "https://gisco-services.ec.europa.eu/distribution/v2/nuts/geojson/NUTS_RG_20M_2021_4326_LEVL_0.geojson";
const STATISTICS = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/nama_10r_2gdp?format=JSON&lang=EN&unit=EUR_HAB&time=2022";
const STOPS = [
{ from: 70000, color: "#99000d", label: "€70,000 and above" },
{ from: 45000, color: "#e34a33", label: "€45,000 to €70,000" },
{ from: 30000, color: "#fc8d59", label: "€30,000 to €45,000" },
{ from: 20000, color: "#fdcc8a", label: "€20,000 to €30,000" },
{ from: 0, color: "#fef0d9", label: "under €20,000" },
];
function colorFor(value) {
if (value === null) return "#e0e0e0";
return STOPS.find((stop) => value >= stop.from).color;
}
const map = L.map("map").setView([52, 10], 4);
L.tileLayer("https://rtc-cdn.maptoolkit.net/rtc/maptoolkit-maptoolkit.summer/{z}/{x}/{y}{r}.png?api_key=YOUR_API_KEY", {
maxZoom: 18,
attribution:
"© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a> " +
"© <a href='https://www.openstreetmap.org/copyright' target='_blank'>OSM</a>",
}).addTo(map);
// JSON-stat: `value` is keyed by position, and the geo dimension maps each code to a position.
function toLookup(jsonStat) {
const index = jsonStat.dimension.geo.category.index;
const lookup = {};
for (const [code, position] of Object.entries(index)) {
const value = jsonStat.value[position];
if (value !== undefined && value !== null) lookup[code] = value;
}
return lookup;
}
Promise.all([
fetch(BOUNDARIES).then((response) => response.json()),
fetch(STATISTICS).then((response) => response.json()),
]).then(([boundaries, statistics]) => {
const gdp = toLookup(statistics);
const states = boundaries.features.filter((f) => f.properties.EU_STAT === "T");
const layer = L.geoJSON(states, {
// Called once per feature: the returned options style that feature.
style: (feature) => ({
fillColor: colorFor(gdp[feature.properties.NUTS_ID] ?? null),
fillOpacity: 0.9,
color: "#ffffff",
weight: 1,
}),
onEachFeature: (feature, featureLayer) => {
const value = gdp[feature.properties.NUTS_ID];
featureLayer.bindPopup(
"<strong>" + feature.properties.NAME_ENGL + "</strong><br>" +
(value ? "€" + value.toLocaleString("en") + " per capita" : "No data")
);
featureLayer.on({
mouseover: () => featureLayer.setStyle({ weight: 3, color: "#333333" }),
mouseout: () => layer.resetStyle(featureLayer),
});
},
attribution: "© <a href='https://ec.europa.eu/eurostat' target='_blank'>Eurostat</a>, © EuroGeographics",
}).addTo(map);
});
// A legend is a Leaflet control whose onAdd returns the element to show.
const legend = L.control({ position: "bottomleft" });
legend.onAdd = () => {
const div = L.DomUtil.create("div", "legend");
div.innerHTML =
"<strong>GDP per capita, 2022</strong><br>" +
STOPS.map((stop) => '<i style="background:' + stop.color + '"></i>' + stop.label).join("<br>") +
'<br><i style="background:#e0e0e0"></i>No data';
return div;
};
legend.addTo(map);
</script>
</body>
</html>Use the prompt below with any LLM to get the same result. Make sure the Maptoolkit MCP server is connected first — check out AI Integration & MCP to get started.
How it works
style is a function of the feature. Leaflet calls it for every polygon and uses the
returned object as that polygon’s path options, so fillColor comes from the country’s own
value. colorFor() walks the classes from the top down and returns the first one the value
reaches. Unknown values get grey, so a missing figure does not read as a low one.
style only applies to lines and polygons. Point features become markers, which have no path
options. To style points, return an L.circleMarker from the pointToLayer option.
onEachFeature wires up each country. It binds the popup and two event handlers.
setStyle() thickens the outline on hover, and layer.resetStyle() puts the feature back by
calling the style function again, so the highlight never has to remember the previous color.
The data is joined in the browser. Boundaries and figures come from two services and meet
on the NUTS code: NUTS_ID on each boundary, and the same code as the key of the statistics
table. toLookup() turns Eurostat’s JSON-stat response into a plain { code: value } object for
that join.
The legend is a control. L.control() with an onAdd function that returns an element is
all a legend needs. Leaflet places it in the chosen corner and keeps it clear of the other
controls. The legend reads from the same STOPS array as the style, so the two cannot drift
apart.
The attribution option on L.geoJSON adds the Eurostat credit next to the Maptoolkit one, and
the raster tile layer needs its own, because a tile image carries no credit.
The view is fixed with setView. The France boundary includes its
overseas regions, French Guiana and Réunion among them, so fitBounds on this layer zooms out
to most of the world.
Next steps
On raster tiles the place names are part of the tile images, so any layer you add sits on top of them. The fill here is close to opaque and covers the labels beneath each country, which reads better than labels showing half through the color. To keep the labels visible above the shapes, use vector tiles: the choropleth in Maptoolkit Maps JS inserts the same data below the label layers.
For your own data, only the join changes: load your figures, key them by the code your
boundaries carry, and keep the style function as it is. Region-level shading works the same
way with the LEVL_2 boundary file and the matching statistics.