Skip to content

Add a Multidirectional Hillshade Layer in MapLibre GL JS

A standard hillshade lights the terrain from one direction, which leaves slopes facing away from the light flat or uniformly dark. MapLibre GL JS can also light it from several directions at once, each with its own highlight and shadow color, so detail shows on every side of a mountain. This example draws a pure relief map of the Alps around Innsbruck from the Maptoolkit terrain tiles, with four colored lights, and a switch to compare it with the standard method.

const API_KEY = "YOUR_API_KEY";

    // Four lights, from the west, northwest, north and northeast, each with its own colors.
    const MULTIDIRECTIONAL = {
      "hillshade-method": "multidirectional",
      "hillshade-highlight-color": ["#ff4000", "#ffff00", "#40ff00", "#00ff80"],
      "hillshade-shadow-color": ["#00bfff", "#0000ff", "#bf00ff", "#ff0080"],
      "hillshade-illumination-direction": [270, 315, 0, 45],
      "hillshade-illumination-altitude": [30, 30, 30, 30],
    };
    // One light from the northwest, with the default colors.
    const STANDARD = {
      "hillshade-method": "standard",
      "hillshade-highlight-color": "#ffffff",
      "hillshade-shadow-color": "#000000",
      "hillshade-illumination-direction": 335,
      "hillshade-illumination-altitude": 45,
    };

    // A style with no basemap: only the terrain source and the hillshade layer.
    const map = new maplibregl.Map({
      container: "map",
      style: {
        version: 8,
        sources: {
          dem: {
            type: "raster-dem",
            tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
            tileSize: 256,
            minzoom: 5,
            maxzoom: 12,
            // Required: the Maptoolkit terrain tiles are Terrarium-encoded.
            encoding: "terrarium",
            attribution: "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a>",
          },
        },
        layers: [
          { id: "background", type: "background", paint: { "background-color": "#f4f2ee" } },
          { id: "hillshade", type: "hillshade", source: "dem", paint: MULTIDIRECTIONAL },
        ],
      },
      center: [11.39085, 47.27574],
      zoom: 10,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    document.querySelectorAll("#modes button").forEach((button) => button.addEventListener("click", () => {
      const paint = button.dataset.method === "multidirectional" ? MULTIDIRECTIONAL : STANDARD;
      // Replaced, not updated: MapLibre cannot animate a four-light array into a single value.
      map.removeLayer("hillshade");
      map.addLayer({ id: "hillshade", type: "hillshade", source: "dem", paint });
      document.querySelectorAll("#modes button").forEach((b) => b.classList.toggle("active", b === button));
    }));
<!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%; }
    #modes {
      position: absolute; top: 12px; left: 12px; z-index: 1; display: flex; gap: 3px; padding: 3px;
      background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
    }
    #modes button {
      padding: 6px 10px; border: none; border-radius: 7px; background: none; cursor: pointer;
      font: 600 12px system-ui, sans-serif; color: #4a5068;
    }
    #modes button.active { background: #303f7e; color: #fff; }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="modes">
    <button data-method="multidirectional" class="active">Multidirectional</button>
    <button data-method="standard">Standard</button>
  </div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    // Four lights, from the west, northwest, north and northeast, each with its own colors.
    const MULTIDIRECTIONAL = {
      "hillshade-method": "multidirectional",
      "hillshade-highlight-color": ["#ff4000", "#ffff00", "#40ff00", "#00ff80"],
      "hillshade-shadow-color": ["#00bfff", "#0000ff", "#bf00ff", "#ff0080"],
      "hillshade-illumination-direction": [270, 315, 0, 45],
      "hillshade-illumination-altitude": [30, 30, 30, 30],
    };
    // One light from the northwest, with the default colors.
    const STANDARD = {
      "hillshade-method": "standard",
      "hillshade-highlight-color": "#ffffff",
      "hillshade-shadow-color": "#000000",
      "hillshade-illumination-direction": 335,
      "hillshade-illumination-altitude": 45,
    };

    // A style with no basemap: only the terrain source and the hillshade layer.
    const map = new maplibregl.Map({
      container: "map",
      style: {
        version: 8,
        sources: {
          dem: {
            type: "raster-dem",
            tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
            tileSize: 256,
            minzoom: 5,
            maxzoom: 12,
            // Required: the Maptoolkit terrain tiles are Terrarium-encoded.
            encoding: "terrarium",
            attribution: "© <a href='https://www.maptoolkit.com' target='_blank'>Maptoolkit</a>",
          },
        },
        layers: [
          { id: "background", type: "background", paint: { "background-color": "#f4f2ee" } },
          { id: "hillshade", type: "hillshade", source: "dem", paint: MULTIDIRECTIONAL },
        ],
      },
      center: [11.39085, 47.27574],
      zoom: 10,
      attributionControl: { compact: false },
    });
    map.addControl(new maplibregl.NavigationControl(), "top-right");

    document.querySelectorAll("#modes button").forEach((button) => button.addEventListener("click", () => {
      const paint = button.dataset.method === "multidirectional" ? MULTIDIRECTIONAL : STANDARD;
      // Replaced, not updated: MapLibre cannot animate a four-light array into a single value.
      map.removeLayer("hillshade");
      map.addLayer({ id: "hillshade", type: "hillshade", source: "dem", paint });
      document.querySelectorAll("#modes button").forEach((b) => b.classList.toggle("active", b === button));
    }));
  </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 relief map around Innsbruck from a style with only the Maptoolkit terrain tiles as a terrarium-encoded raster-dem source and a multidirectional hillshade layer lit from four directions in different colors, with buttons to switch between multidirectional and standard hillshade.

How it works

hillshade-method: "multidirectional" is the switch. With it, the illumination properties take arrays instead of single values: one entry per light. Here four lights shine from 270° (west), 315°, 0° (north) and 45°, all 30° above the horizon, and each has its own highlight and shadow color. Slopes that face away from one light face another, so every side of a mountain keeps its detail. The arrays must have the same length.

The multidirectional method needs MapLibre GL JS 5.5 or later.

Switching methods at runtime. The buttons remove the layer and add it again with the other paint properties. Changing them one by one with setPaintProperty() fails: MapLibre animates paint changes, and it cannot interpolate between four colors and one, so it logs Arrays have mismatched length (4 vs. 1). The standard method takes single values: one direction and one pair of colors.

A style with no basemap. The style object has only a background, the terrain source and the hillshade layer, which makes a pure relief map without roads or labels. To shade a normal map instead, add the source and layer to a Maptoolkit style, as in Add a Hillshade Layer. A style built by hand has no attribution of its own, so the source carries it.

raster-dem with encoding: "terrarium". MapLibre reads each pixel of the terrain tiles as a height. The Maptoolkit tiles are Terrarium-encoded, and the wrong encoding raises no error, it just draws wrong relief.

The colors here are deliberately loud to show where each light falls. For a map people read, use one hue: shades of gray or warm browns for the shadows, and white or pale yellow for the highlights.

Next steps

A relief map becomes a topographic map with contour lines and a few labeled summits, whose heights the Elevation API provides.

The same example in Maptoolkit Maps JS is Add a Multidirectional Hillshade Layer.