Skip to content

Fall Back to a Static Map in MapLibre GL JS

MapLibre GL JS needs WebGL, and a small share of visitors don’t have it: locked-down corporate browsers, old devices, and anyone with hardware acceleration switched off. Without it, the map container stays empty. This example checks for WebGL before creating the map and, if it is missing, shows an image of the same view from the Maptoolkit Static Maps API, with the same pin. The button simulates the fallback.

const API_KEY = "YOUR_API_KEY";

    const VIEW = { center: [11.39085, 47.27574], zoom: 13 };
    // One pin image for both maps, drawn at twice its display size so it stays sharp with factor 2.
    // The Static Maps API downloads it itself, so it must be a public, absolute URL.
    const PIN = { url: "https://docs.maptoolkit.com/demos/maps-js-fall-back-to-a-static-map-pin.png", width: 27, height: 36 };

    // Ask for a real context: the WebGLRenderingContext constructor alone does not prove one is available.
    function webglSupported() {
      try {
        const canvas = document.createElement("canvas");
        const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
        return Boolean(gl) && gl.getSupportedExtensions() !== null;
      } catch {
        return false;
      }
    }

    function staticMapUrl(width, height) {
      const [lng, lat] = VIEW.center;
      const url = new URL("https://staticmap.maptoolkit.net");
      url.searchParams.set("maptype", "maptoolkit-maptoolkit.summer");
      // The Static Maps API takes lat,lng.
      url.searchParams.set("center", `${lat},${lng}`);
      // It counts 256 px tiles, MapLibre counts 512 px tiles: the same view is one zoom level higher.
      url.searchParams.set("zoom", VIEW.zoom + 1);
      url.searchParams.set("size", `${Math.min(Math.round(width), 1280)}x${Math.min(Math.round(height), 1280)}`);
      // Twice the resolution for sharp high-density screens. The pin is not scaled, hence the double-size PNG.
      url.searchParams.set("factor", 2);
      url.searchParams.set("marker", `icon:${PIN.url}|anchor:bottom|center:${lat},${lng}`);
      url.searchParams.set("api_key", API_KEY);
      return url;
    }

    function showStatic(reason) {
      const container = document.getElementById("map");
      const { width, height } = container.getBoundingClientRect();
      const img = Object.assign(document.createElement("img"), {
        className: "static-fallback", src: staticMapUrl(width, height), alt: "Map of Innsbruck",
      });
      container.replaceChildren(img);
      document.getElementById("note").innerHTML =
        `<strong>Static map</strong>${reason} The image needs no WebGL and loads in one request.`;
    }

    function showInteractive() {
      const map = new maplibregl.Map({
        container: "map",
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: VIEW.center,
        zoom: VIEW.zoom,
        attributionControl: { compact: false },
      });
      map.addControl(new maplibregl.NavigationControl(), "top-right");

      const pin = Object.assign(document.createElement("img"), { src: PIN.url, width: PIN.width, height: PIN.height, alt: "" });
      new maplibregl.Marker({ element: pin, anchor: "bottom" }).setLngLat(VIEW.center).addTo(map);

      // WebGL can also be lost later, when a graphics driver resets. The same fallback covers it.
      map.on("webglcontextlost", () => {
        map.remove();
        showStatic("The graphics context was lost.");
      });

      document.getElementById("note").innerHTML =
        "<strong>Interactive map</strong>WebGL is available, so MapLibre renders the vector map." +
        '<br><button id="simulate">Show the fallback</button>';
      document.getElementById("simulate").addEventListener("click", () => {
        map.remove();
        showStatic("Simulated: no WebGL.");
      });
    }

    if (webglSupported()) showInteractive();
    else showStatic("This browser has no WebGL.");
<!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%; }
    #map > img.static-fallback { display: block; width: 100%; height: 100%; object-fit: cover; }
    #note {
      position: absolute; top: 12px; left: 12px; z-index: 1; max-width: 250px; padding: 10px 12px;
      background: #fff; border-radius: 10px; box-shadow: 0 4px 20px rgba(20, 30, 60, 0.18);
      font: 13px/1.5 system-ui, sans-serif; color: #1f2430;
    }
    #note strong { display: block; }
    #note button {
      margin-top: 8px; padding: 6px 10px; border: none; border-radius: 7px; background: #303f7e;
      color: #fff; font: 600 12px system-ui, sans-serif; cursor: pointer;
    }
  </style>
</head>
<body>
  <div id="map"></div>
  <div id="note"></div>
  <script>
    const API_KEY = "YOUR_API_KEY";

    const VIEW = { center: [11.39085, 47.27574], zoom: 13 };
    // One pin image for both maps, drawn at twice its display size so it stays sharp with factor 2.
    // The Static Maps API downloads it itself, so it must be a public, absolute URL.
    const PIN = { url: "https://docs.maptoolkit.com/demos/maps-js-fall-back-to-a-static-map-pin.png", width: 27, height: 36 };

    // Ask for a real context: the WebGLRenderingContext constructor alone does not prove one is available.
    function webglSupported() {
      try {
        const canvas = document.createElement("canvas");
        const gl = canvas.getContext("webgl2") || canvas.getContext("webgl");
        return Boolean(gl) && gl.getSupportedExtensions() !== null;
      } catch {
        return false;
      }
    }

    function staticMapUrl(width, height) {
      const [lng, lat] = VIEW.center;
      const url = new URL("https://staticmap.maptoolkit.net");
      url.searchParams.set("maptype", "maptoolkit-maptoolkit.summer");
      // The Static Maps API takes lat,lng.
      url.searchParams.set("center", `${lat},${lng}`);
      // It counts 256 px tiles, MapLibre counts 512 px tiles: the same view is one zoom level higher.
      url.searchParams.set("zoom", VIEW.zoom + 1);
      url.searchParams.set("size", `${Math.min(Math.round(width), 1280)}x${Math.min(Math.round(height), 1280)}`);
      // Twice the resolution for sharp high-density screens. The pin is not scaled, hence the double-size PNG.
      url.searchParams.set("factor", 2);
      url.searchParams.set("marker", `icon:${PIN.url}|anchor:bottom|center:${lat},${lng}`);
      url.searchParams.set("api_key", API_KEY);
      return url;
    }

    function showStatic(reason) {
      const container = document.getElementById("map");
      const { width, height } = container.getBoundingClientRect();
      const img = Object.assign(document.createElement("img"), {
        className: "static-fallback", src: staticMapUrl(width, height), alt: "Map of Innsbruck",
      });
      container.replaceChildren(img);
      document.getElementById("note").innerHTML =
        `<strong>Static map</strong>${reason} The image needs no WebGL and loads in one request.`;
    }

    function showInteractive() {
      const map = new maplibregl.Map({
        container: "map",
        style: `https://styles.maptoolkit.net/maptoolkit/maptoolkit.summer.json?api_key=${API_KEY}`,
        center: VIEW.center,
        zoom: VIEW.zoom,
        attributionControl: { compact: false },
      });
      map.addControl(new maplibregl.NavigationControl(), "top-right");

      const pin = Object.assign(document.createElement("img"), { src: PIN.url, width: PIN.width, height: PIN.height, alt: "" });
      new maplibregl.Marker({ element: pin, anchor: "bottom" }).setLngLat(VIEW.center).addTo(map);

      // WebGL can also be lost later, when a graphics driver resets. The same fallback covers it.
      map.on("webglcontextlost", () => {
        map.remove();
        showStatic("The graphics context was lost.");
      });

      document.getElementById("note").innerHTML =
        "<strong>Interactive map</strong>WebGL is available, so MapLibre renders the vector map." +
        '<br><button id="simulate">Show the fallback</button>';
      document.getElementById("simulate").addEventListener("click", () => {
        map.remove();
        showStatic("Simulated: no WebGL.");
      });
    }

    if (webglSupported()) showInteractive();
    else showStatic("This browser has no WebGL.");
  </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 page that checks for WebGL support and renders an interactive MapLibre GL JS map with the Maptoolkit summer style and a pin when available, or a Maptoolkit Static Maps API image of the same view with the same pin when not, with a button to simulate the fallback.

How it works

Check before creating the map. Without WebGL, new maplibregl.Map() fails and the container stays blank. MapLibre no longer ships a supported() function, so the check is done by hand: it asks a canvas for a webgl2 or webgl context, the same contexts MapLibre asks for. getSupportedExtensions() returns null on a lost or stubbed context, which catches browsers that return a context object but cannot draw with it.

The same view as an image. The Static Maps API renders the same style from a URL, so the fallback is an <img>. Two numbers differ between the two:

  • center is lat,lng, the opposite of MapLibre’s [lng, lat].
  • zoom is one higher. The Static Maps API counts 256 px tiles and MapLibre 512 px tiles, so MapLibre’s zoom passed straight through would show four times the area.

size is capped at 1280 px per side, which factor: 2 renders as 2560 px: sharp enough for any container, without making a large monitor download a huge image. object-fit: cover fills the rest.

The same pin on both. The static image gets the pin as icon: in the marker parameter, and MapLibre as the element of its Marker. With the same PNG and anchor: bottom on both, the pin does not jump in shape, size or position when the fallback takes over. factor: 2 doubles the resolution of the image but not of the icon, so the PNG is drawn at 54 by 72 pixels for a 27 by 36 pin. The endpoint downloads the icon itself: a data URI or a localhost path does not work.

Clean up. map.remove() before swapping in the image releases the WebGL context, the event listeners and the tile requests. The same applies when webglcontextlost fires after a successful start, for example after a driver reset, which would otherwise leave a blank map.

Next steps

An image is also the better choice where WebGL works: a list of twenty search results with a small map each should be twenty images, not twenty WebGL contexts, because browsers limit how many can exist at once. The Static Maps API adds markers, paths and bounding boxes to the image.

The same example in Maptoolkit Maps JS is Fall Back to a Static Map.