Add 3D Terrain in Mapbox GL JS
Mapbox GL JS requires an access token from mapbox.com in addition to your Maptoolkit API key.
Map with 3D Terrain
Adds a 3D terrain effect using the Terrain RGB tileset. Set pitch to tilt the map, then add the terrain source and call setTerrain:
const API_KEY = 'YOUR_API_KEY';
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
const STYLE_URL = `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`;
// Maptoolkit styles declare `sprite` as an array, which is the MapLibre extension for
// several sprite sheets. Mapbox GL JS only accepts a string and rejects the whole style
// with "sprite: string expected, array found", so collapse it before handing it over.
async function loadStyleForMapbox(url) {
const style = await fetch(url).then((r) => r.json());
if (Array.isArray(style.sprite)) style.sprite = style.sprite[0].url;
return style;
}
loadStyleForMapbox(STYLE_URL).then((style) => {
const map = new mapboxgl.Map({
container: 'map',
style,
center: [12.805988, 47.310897],
zoom: 12,
pitch: 65,
});
map.on('load', () => {
map.addSource('terrain', {
type: 'raster-dem',
tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
tileSize: 256,
maxzoom: 12,
minzoom: 5,
encoding: 'terrarium'
});
map.setTerrain({ source: 'terrain' });
});
map.addControl(new mapboxgl.NavigationControl({ visualizePitch: true }));
});<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.js"></script>
<link rel="stylesheet" href="https://api.mapbox.com/mapbox-gl-js/v3.3.0/mapbox-gl.css" />
<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';
mapboxgl.accessToken = 'YOUR_MAPBOX_TOKEN';
const STYLE_URL = `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`;
// Maptoolkit styles declare `sprite` as an array, which is the MapLibre extension for
// several sprite sheets. Mapbox GL JS only accepts a string and rejects the whole style
// with "sprite: string expected, array found", so collapse it before handing it over.
async function loadStyleForMapbox(url) {
const style = await fetch(url).then((r) => r.json());
if (Array.isArray(style.sprite)) style.sprite = style.sprite[0].url;
return style;
}
loadStyleForMapbox(STYLE_URL).then((style) => {
const map = new mapboxgl.Map({
container: 'map',
style,
center: [12.805988, 47.310897],
zoom: 12,
pitch: 65,
});
map.on('load', () => {
map.addSource('terrain', {
type: 'raster-dem',
tiles: [`https://tiles.maptoolkit.net/terrain/{z}/{x}/{y}.webp?api_key=${API_KEY}`],
tileSize: 256,
maxzoom: 12,
minzoom: 5,
encoding: 'terrarium'
});
map.setTerrain({ source: 'terrain' });
});
map.addControl(new mapboxgl.NavigationControl({ visualizePitch: true }));
});
</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
The style has to be patched before Mapbox GL JS will accept it. Maptoolkit styles declare
sprite as an array of {id, url} objects, which is the MapLibre extension for loading
several sprite sheets into one style. Mapbox GL JS implements only the original single-string
form, validates the style on load and rejects the whole thing with
Error: sprite: string expected, array found. The map then stays blank and map.getStyle()
throws, because no style was ever loaded.
Fetching the JSON and collapsing sprite to the first sheet’s URL is the fix, and it is why
the map is constructed inside a then rather than at the top level.
That collapse loses the other sheets. Summer, Street, Light and Dark ship a single sprite, so nothing is lost there. Winter, Hiking and Cycling ship several, and icons drawn from the second sheet are missing in Mapbox GL JS however you load them. Prefer a single-sprite style here, or use MapLibre GL JS or Maps JS, which support the array form natively and need no patching.
Terrain is two separate things: a source that carries elevation, and a camera angle that makes it visible.
The source is raster-dem, not raster. A raster source would paint the tiles as a
picture; raster-dem tells the renderer to read each pixel as a height value.
encoding: 'terrarium' is required and easy to get wrong. The tiles are Terrarium-encoded
even though the path says terrain and much Mapbox tooling assumes Terrain-RGB. The wrong
encoding does not raise an error, it decodes the same pixels with the wrong formula and
produces plausible but incorrect relief.
setTerrain has to run after the style has loaded, which is why it sits inside
map.on('load'). Calling it at construction time throws, because the style is not there to
attach to yet.
pitch: 65 is what makes the relief visible. Without it you are looking straight down at
terrain that is there but invisible. visualizePitch: true adds the tilt indicator to the
navigation control so users can find the control themselves.
minzoom: 5 and maxzoom: 12 describe the tileset rather than a preference. Zoom 12 is the
maximum it holds, so the renderer overzooms past it instead of fetching more detail.
Next steps
Tuning comes first. exaggeration on setTerrain scales the relief, and values above 1
make mountains read better at the cost of misrepresenting the ground.
Terrain is most useful with something on it. A route drawn over tilted terrain reads far better than the same route flat, which is what the Routing API returns, and the Elevation API turns the same line into a climb profile. Contour lines from the same DEM add measured heights to the shading.