Add a Legend for a Data-Driven Layer in Maptoolkit Maps JS
A data-driven map that does not explain its colours is decoration. The usual fix is to hand-write a legend in HTML, which works until someone changes a colour in the style and forgets the legend. This example derives the legend from the same array of stops that builds the paint expression, so there is one definition and the two cannot disagree.
const API_KEY = 'YOUR_API_KEY';
// One definition. The expression and the legend are both built from this.
const STOPS = [
{ from: 0, color: '#fee5d9', label: 'under 2' },
{ from: 2, color: '#fcae91', label: '2 to 3' },
{ from: 3, color: '#fb6a4a', label: '3 to 4' },
{ from: 4, color: '#de2d26', label: '4 to 5' },
{ from: 5, color: '#a50f15', label: '5 and above' }
];
// step wants: [step, input, <value below first stop>, stop1, value1, stop2, value2, ...]
function stepExpression(property, stops) {
const expression = ['step', ['get', property], stops[0].color];
for (const stop of stops.slice(1)) expression.push(stop.from, stop.color);
return expression;
}
function renderLegend(title, stops) {
document.getElementById('legend').innerHTML =
`<h4>${title}</h4>` +
stops.map(s => `<div class="row"><span class="sw" style="background:${s.color}"></span><span>${s.label}</span></div>`).join('');
}
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [-119.5, 36.0],
zoom: 5,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson'
});
map.addLayer({
id: 'earthquakes',
type: 'circle',
source: 'earthquakes',
paint: {
'circle-color': stepExpression('mag', STOPS),
'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 3, 6, 14],
'circle-stroke-color': '#fff',
'circle-stroke-width': 1,
'circle-opacity': 0.9
}
});
renderLegend('Magnitude', STOPS);
});<!DOCTYPE html>
<html lang="en">
<head>
<title>Add a Legend - Maptoolkit Maps JS</title>
<meta property="og:description" content="Build a legend from the same stops that colour the layer." />
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script src="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.js"></script>
<link rel="stylesheet" href="https://unpkg.com/@maptoolkit/maps@11.0.0-beta.3/dist/maptoolkit.css" />
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
.mtk-legend {
position: absolute; bottom: 30px; right: 10px; z-index: 999;
background: #fff; border-radius: 6px; box-shadow: 0 0 15px #68686880;
font: 12px/1.5 system-ui, sans-serif; padding: 10px 12px;
}
.mtk-legend h4 { margin: 0 0 6px; font-size: 12px; font-weight: 600; }
.mtk-legend .row { display: flex; align-items: center; gap: 8px; }
.mtk-legend .sw { width: 12px; height: 12px; border-radius: 50%; flex: none; border: 1px solid #0003; }
</style>
</head>
<body>
<div id="map"></div>
<div class="mtk-legend" id="legend"></div>
<script>
const API_KEY = 'YOUR_API_KEY';
// One definition. The expression and the legend are both built from this.
const STOPS = [
{ from: 0, color: '#fee5d9', label: 'under 2' },
{ from: 2, color: '#fcae91', label: '2 to 3' },
{ from: 3, color: '#fb6a4a', label: '3 to 4' },
{ from: 4, color: '#de2d26', label: '4 to 5' },
{ from: 5, color: '#a50f15', label: '5 and above' }
];
// step wants: [step, input, <value below first stop>, stop1, value1, stop2, value2, ...]
function stepExpression(property, stops) {
const expression = ['step', ['get', property], stops[0].color];
for (const stop of stops.slice(1)) expression.push(stop.from, stop.color);
return expression;
}
function renderLegend(title, stops) {
document.getElementById('legend').innerHTML =
`<h4>${title}</h4>` +
stops.map(s => `<div class="row"><span class="sw" style="background:${s.color}"></span><span>${s.label}</span></div>`).join('');
}
const map = new maptoolkit.Map({
container: 'map',
apiKey: API_KEY,
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
center: [-119.5, 36.0],
zoom: 5,
attributionControl: { compact: false }
});
map.addControl(new maptoolkit.NavigationControl(), 'top-right');
map.on('load', () => {
map.addSource('earthquakes', {
type: 'geojson',
data: 'https://maplibre.org/maplibre-gl-js/docs/assets/earthquakes.geojson'
});
map.addLayer({
id: 'earthquakes',
type: 'circle',
source: 'earthquakes',
paint: {
'circle-color': stepExpression('mag', STOPS),
'circle-radius': ['interpolate', ['linear'], ['get', 'mag'], 1, 3, 6, 14],
'circle-stroke-color': '#fff',
'circle-stroke-width': 1,
'circle-opacity': 0.9
}
});
renderLegend('Magnitude', STOPS);
});
</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
STOPS is the only place the encoding is written down. stepExpression turns it into a
MapLibre step expression and renderLegend turns it into swatches, so adding a class or
changing a colour touches one array and both follow.
The shape of step is the part worth knowing. It is
['step', input, valueBelowFirstStop, stop1, value1, stop2, value2, ...], so the first
colour is not paired with a number. It is the value used below the first threshold. That
asymmetry is why the builder starts the expression with stops[0].color and then iterates
from stops.slice(1). Writing it as evenly paired arguments produces an expression that
either fails validation or silently shifts every band by one.
step is the right choice here rather than interpolate. Interpolation implies the values
in between mean something, and a legend with five discrete swatches then lies about a
continuous ramp. Use step when the legend has rows and interpolate when it has a gradient
bar.
Labels are written out rather than generated from the numbers, because the last band has no upper bound and the first has no lower one. Generating “0 to 2, 2 to 3” from the array is easy; generating “5 and above” for the final row and getting the boundary wording right is where it stops being worth it.
The legend sits in ordinary DOM outside the map rather than in a control. That keeps it styleable with the rest of the page and out of the way of the attribution, which must stay visible.
Next steps
A legend belongs anywhere colour carries meaning, and the same array drives more than one
layer: reusing STOPS for a circle-color and a matching line-color keeps a point layer
and its connecting lines in step.
For a continuous ramp rather than bands, swap step for interpolate and render the legend
as a CSS gradient with a few labelled ticks. The Weather API layers are the
usual case, where a temperature scale has to stay fixed rather than adapting to what is on
screen, or the same colour means something different on each load.