Skip to content

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.

Use the Maptoolkit Connector. Create a MapLibre GL JS map with the Maptoolkit light style over the Nordkette near Innsbruck. Add the Maptoolkit contours vector tiles as a source, draw all contours thin and index contours heavier below the labels, and write the heights along the index contours.

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.