Add Contour Lines in MapLibre GL JS
Contour lines show elevation as lines of equal height, which is how you read the shape and steepness of terrain at a glance. The Summer, Winter, Hiking, Cycling and Night styles draw them already; Light and Street don’t. Maptoolkit also serves contours as their own vector tiles, so you can add them to any style and draw them your way. This example adds them to the Light style over the Nordkette above Innsbruck, with a heavier index contour about every fifth line and the height written along it.
const API_KEY = "YOUR_API_KEY";
const map = new maplibregl.Map({
container: "map",
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.light.json?api_key=${API_KEY}`,
center: [11.3782, 47.3095],
zoom: 14,
attributionControl: { compact: false },
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.once("style.load", () => {
// One TileJSON for contours and bathymetry; each layer picks one with source-layer.
// Some Maptoolkit styles already have a source named "contours", so this one gets its own id.
map.addSource("mtk-contours", {
type: "vector",
url: `https://dataconnector.maptoolkit.net/maptoolkit/mtk-contours-bathymetry.json?api_key=${API_KEY}`,
});
// Below the first label layer, so place names stay readable over the lines.
const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
// divisor is the roundest number that divides the height. Requiring a large divisor picks
// out about every fifth line at each zoom, even though the interval changes with zoom.
const isIndex = [">=", ["get", "divisor"], ["step", ["zoom"], 500, 12, 200, 14, 100]];
map.addLayer({
id: "contours", type: "line", source: "mtk-contours", "source-layer": "contours", minzoom: 11,
paint: {
"line-color": "#8a6a4a",
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.3, 15, 0.8],
"line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12.5, 0.65],
},
}, firstSymbolId);
map.addLayer({
id: "contours-index", type: "line", source: "mtk-contours", "source-layer": "contours", minzoom: 11,
filter: isIndex,
paint: {
"line-color": "#7a5533",
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.7, 15, 1.8],
"line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12.5, 0.85],
},
}, firstSymbolId);
// Added last, above the basemap labels: below them it would lose most label collisions.
map.addLayer({
id: "contours-label", type: "symbol", source: "mtk-contours", "source-layer": "contours", minzoom: 12,
filter: isIndex,
layout: {
"symbol-placement": "line",
"text-field": ["concat", ["to-string", ["get", "ele"]], " m"],
"text-font": ["Roboto Regular"],
"text-size": 10,
// Contours bend a lot; a tight limit would reject almost every label position.
"text-max-angle": 60,
"text-padding": 2,
"symbol-spacing": 120,
},
paint: { "text-color": "#6b4a2b", "text-halo-color": "#ffffff", "text-halo-width": 1.4 },
});
});<!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/maplibre-gl@5.6.1/dist/maplibre-gl.css" />
<script src="https://cdn.jsdelivr.net/npm/maplibre-gl@5.6.1/dist/maplibre-gl.js"></script>
<style>
html, body { width: 100%; height: 100%; margin: 0; padding: 0; }
#map { width: 100%; height: 100%; }
</style>
</head>
<body>
<div id="map"></div>
<script>
const API_KEY = "YOUR_API_KEY";
const map = new maplibregl.Map({
container: "map",
style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.light.json?api_key=${API_KEY}`,
center: [11.3782, 47.3095],
zoom: 14,
attributionControl: { compact: false },
});
map.addControl(new maplibregl.NavigationControl(), "top-right");
map.once("style.load", () => {
// One TileJSON for contours and bathymetry; each layer picks one with source-layer.
// Some Maptoolkit styles already have a source named "contours", so this one gets its own id.
map.addSource("mtk-contours", {
type: "vector",
url: `https://dataconnector.maptoolkit.net/maptoolkit/mtk-contours-bathymetry.json?api_key=${API_KEY}`,
});
// Below the first label layer, so place names stay readable over the lines.
const firstSymbolId = map.getStyle().layers.find((layer) => layer.type === "symbol")?.id;
// divisor is the roundest number that divides the height. Requiring a large divisor picks
// out about every fifth line at each zoom, even though the interval changes with zoom.
const isIndex = [">=", ["get", "divisor"], ["step", ["zoom"], 500, 12, 200, 14, 100]];
map.addLayer({
id: "contours", type: "line", source: "mtk-contours", "source-layer": "contours", minzoom: 11,
paint: {
"line-color": "#8a6a4a",
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.3, 15, 0.8],
"line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12.5, 0.65],
},
}, firstSymbolId);
map.addLayer({
id: "contours-index", type: "line", source: "mtk-contours", "source-layer": "contours", minzoom: 11,
filter: isIndex,
paint: {
"line-color": "#7a5533",
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 0.7, 15, 1.8],
"line-opacity": ["interpolate", ["linear"], ["zoom"], 11, 0, 12.5, 0.85],
},
}, firstSymbolId);
// Added last, above the basemap labels: below them it would lose most label collisions.
map.addLayer({
id: "contours-label", type: "symbol", source: "mtk-contours", "source-layer": "contours", minzoom: 12,
filter: isIndex,
layout: {
"symbol-placement": "line",
"text-field": ["concat", ["to-string", ["get", "ele"]], " m"],
"text-font": ["Roboto Regular"],
"text-size": 10,
// Contours bend a lot; a tight limit would reject almost every label position.
"text-max-angle": 60,
"text-padding": 2,
"symbol-spacing": 120,
},
paint: { "text-color": "#6b4a2b", "text-halo-color": "#ffffff", "text-halo-width": 1.4 },
});
});
</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
Contours are vector tiles. They are not computed in the browser from the terrain DEM. The
TileJSON serves contours and bathymetry together, so one addSource covers both, and each layer
chooses with source-layer. A source-layer that doesn’t exist draws nothing and logs nothing,
the usual reason a contour layer stays empty. The source id must also be new: the styles that
draw contours already have a source called contours, and addSource with an existing id throws
Source "contours" already exists. On one of those styles, hide their relief_contour_* layers
first, or every line is drawn twice.
Each contour has three fields: ele, the height in meters; divisor, the roundest number that
divides the height; and terrain_type, normal or rock.
The interval already changes with zoom. The tiles hold 100 m contours at zoom 11, 50 m at 12
and 13, 20 m at 14 and 10 m at 15. A filter like “every multiple of 100” matches every line at
zoom 11 but only every tenth at zoom 15. divisor solves that: a step over zoom sets how round
the height of an index contour has to be, so about every fifth line is heavy at any zoom. The
same isIndex expression filters the heavy lines and the labels.
Labels along the line. symbol-placement: "line" writes the height along the contour.
text-max-angle limits how much the text may bend between characters. At 25 degrees, fine for
street names, this map shows no heights at all, because almost every position on a mountain
contour bends more. At 60 it shows about fifteen.
Layer order is split. The two line layers go below the basemap’s first symbol layer, so place names stay on top. The label layer is added last, above everything: MapLibre places labels in layer order, and below the basemap’s labels the heights lose almost every collision.
Next steps
Contours pair well with a hillshade layer: the shading shows the shape of the ground at once, and the contours add the numbers. For the height of one point, the Elevation API returns meters for any coordinate.
The same example in Maptoolkit Maps JS is Add Contour Lines.