Part of the Base Layer Selection & Switching guide.
Operative rule: capture center, zoom, bearing, and pitch before every setStyle() call and re-apply them after the new style loads — a style swap resets the camera and destroys every source and layer you added imperatively.
How Runtime Style Swapping Works
A basemap in maplibregl is not a single tile URL — it is a complete style document: a JSON object listing sources, layers, sprite and glyph endpoints, and default camera state. Switching from an OpenStreetMap raster basemap to a Mapbox vector basemap means replacing that entire document with map.setStyle(newStyle). The critical consequence is that setStyle() is destructive: MapLibre tears down the old style graph and builds a new one from scratch. Any source or layer you added at runtime — GeoJSON overlays, heatmaps, marker layers — is not part of the incoming style JSON, so it vanishes the moment the swap completes.
Two pieces of state therefore need explicit preservation. The first is the camera: center, zoom, bearing, and pitch. MapLibre GL v4 preserves the camera across setStyle() by default, but a style document that hard-codes a center or zoom at its top level will override the live viewport unless you re-apply it. The second is your overlay graph. Because overlays must be re-added after the new style parses, you hook the styledata event (or map.once('style.load', …)), which fires when the incoming style finishes loading. This same discipline underpins a dark-mode basemap toggle and pairs naturally with the best base map providers for high-contrast geo-dashboards, where legibility of overlays depends on the basemap underneath them.
Production-Ready Implementation
The controller below manages two named basemaps — an OpenStreetMap raster style and a Mapbox vector style — and exposes a single switchTo(id) method. It snapshots the camera, swaps the style, and re-registers overlays inside the styledata handler. Overlay definitions are kept in a registry so re-adding is a plain loop rather than duplicated code.
// MapLibre GL JS v4 — runtime basemap switcher with overlay preservation.
import maplibregl from "maplibre-gl";
const MAPBOX_TOKEN = window.MAPBOX_TOKEN || ""; // injected at build time
// Two named basemap styles. OSM raster needs no key; Mapbox vector does.
const BASEMAPS = {
osm: {
version: 8,
sources: {
"osm-raster": {
type: "raster",
tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"],
tileSize: 256,
attribution: "© OpenStreetMap contributors",
},
},
layers: [{ id: "osm-raster", type: "raster", source: "osm-raster" }],
},
mapbox: MAPBOX_TOKEN
? `https://api.mapbox.com/styles/v1/mapbox/streets-v12?access_token=${MAPBOX_TOKEN}`
: null, // null signals "unavailable"; the switcher falls back to OSM
};
// Overlay registry: each entry is re-added after every style swap.
const OVERLAYS = [
{
id: "incidents",
source: { type: "geojson", data: "/data/incidents.geojson" },
layer: {
id: "incidents",
type: "circle",
source: "incidents",
paint: {
"circle-radius": 5,
"circle-color": "#e6550d",
"circle-stroke-width": 1,
"circle-stroke-color": "#ffffff",
},
},
},
];
const map = new maplibregl.Map({
container: "map",
style: BASEMAPS.osm,
center: [-74.006, 40.7128],
zoom: 11,
});
/** Re-add every registered overlay if it is not already present. */
function addOverlays() {
for (const o of OVERLAYS) {
if (!map.getSource(o.id)) map.addSource(o.id, o.source);
if (!map.getLayer(o.layer.id)) map.addLayer(o.layer);
}
}
// Initial overlay load once the first style is ready.
map.on("load", addOverlays);
/**
* Swap the active basemap while preserving the viewport and overlays.
* @param {"osm"|"mapbox"} id
*/
function switchTo(id) {
const nextStyle = BASEMAPS[id];
if (!nextStyle) {
console.warn(`Basemap "${id}" unavailable (missing key); staying on OSM.`);
return; // graceful no-op instead of a blank canvas
}
// 1. Snapshot the camera BEFORE the destructive swap.
const camera = {
center: map.getCenter(),
zoom: map.getZoom(),
bearing: map.getBearing(),
pitch: map.getPitch(),
};
// 2. Re-add overlays + restore camera once the new style has parsed.
// { once: true } avoids stacking handlers across repeated switches.
map.once("styledata", () => {
map.jumpTo(camera); // restore exact viewport
addOverlays(); // rebuild overlay graph on top of new basemap
});
// 3. Perform the swap. diff:false forces a full rebuild between
// raster (OSM) and vector (Mapbox) styles, which are not diff-able.
map.setStyle(nextStyle, { diff: false });
}
// Fall back to OSM if any tile/style request errors (e.g. bad token).
map.on("error", (e) => {
if (String(e.error?.message || "").includes("401")) {
console.error("Mapbox auth failed; reverting to OpenStreetMap.");
switchTo("osm");
}
});
// Wire the UI control.
document
.querySelector("#basemap-select")
?.addEventListener("change", (e) => switchTo(e.target.value));
Alternative Variants
Leaflet tile-layer swap (raster only)
Leaflet has no monolithic style document, so switching basemaps means adding one L.tileLayer and removing the other. The camera never resets because Leaflet does not tear down the map, and overlays added as separate L.Layer objects survive untouched. This is the simpler model when both basemaps are raster tiles and you do not need vector styling. It also composes cleanly with server-side bounds set when configuring maxBounds and minZoom in Leaflet via Python.
// Leaflet raster basemap swap — no camera reset, overlays persist.
const bases = {
osm: L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: "© OpenStreetMap contributors",
maxZoom: 19,
}),
satellite: L.tileLayer(
"https://api.mapbox.com/styles/v1/mapbox/satellite-v9/tiles/{z}/{x}/{y}?access_token=" +
MAPBOX_TOKEN,
{ attribution: "© Mapbox © OpenStreetMap", tileSize: 512, zoomOffset: -1 }
),
};
const map = L.map("map", { center: [40.7128, -74.006], zoom: 11, layers: [bases.osm] });
let active = "osm";
function switchTo(id) {
if (id === active || !bases[id]) return;
map.removeLayer(bases[active]); // camera untouched — no state to restore
map.addLayer(bases[id]);
active = id;
}
Basemap capability reference
| Concern | OpenStreetMap raster | Mapbox vector |
|---|---|---|
| API key required | No | Yes (access_token) |
| Style format | raster tile URL | Style Spec JSON |
| Runtime relabel / recolor | No | Yes (setPaintProperty) |
| Attribution obligation | © OpenStreetMap |
Mapbox + OSM |
setStyle diff-able |
n/a (raster) | Only vs another vector style |
| Blank-on-missing-key risk | None | High — needs fallback |
Verification Steps
- Camera continuity — log
map.getCenter()andmap.getZoom()immediately before and after a swap; the values must be identical to within floating-point noise. - Overlay survival — after switching, run
map.getLayer('incidents')in the console; a non-undefinedreturn confirms the overlay was re-added on the new basemap. - Missing-key fallback — deploy with an empty
MAPBOX_TOKENand confirm the switcher logs a warning and stays on OSM instead of rendering a blank canvas. - Attribution present — inspect the map’s attribution control after each swap; the correct provider credit must appear for whichever basemap is active.
- No handler stacking — switch back and forth ten times, then confirm only one
styledatarestore runs per swap (the{ once: true }guard prevents accumulation).
Common Errors & Fixes
Overlays vanish after switching basemaps
setStyle() replaced the whole style graph, discarding sources and layers you added imperatively. The fix is to re-add them inside a styledata (or map.once('style.load', …)) handler, exactly as addOverlays() does above. Never re-add overlays synchronously right after setStyle() — the new style has not parsed yet, and addLayer will throw Style is not done loading.
Map jumps to a different location after the swap
The incoming style document declares a top-level center and zoom that override the live viewport. Snapshot the camera before the swap and call map.jumpTo(camera) in the styledata handler. Prefer jumpTo over flyTo here so the transition is instantaneous rather than animating away from the user’s current view.
Mapbox basemap renders blank with 401 errors in the console
The access_token is missing, expired, or scoped without style read permission. Requests to the Mapbox tile endpoint return 401 Unauthorized and the canvas stays empty. Guard by treating a null style entry as “unavailable” and falling back to the OpenStreetMap raster style, and attach a map.on('error', …) handler that reverts on auth failure rather than leaving users staring at a void.
Attribution disappears or shows the wrong provider
Each basemap carries its own legal attribution, and a style swap replaces the attribution string along with everything else. Set attribution on every source (maplibregl) or tileLayer (Leaflet) so the correct credit re-appears automatically. Dropping OpenStreetMap or Mapbox attribution violates their usage terms, so treat this as a release blocker, not a cosmetic detail.
Related
- Base Layer Selection & Switching — parent guide covering provider choice, attribution, and switch UX across the rendering stack
- Configuring maxBounds & minZoom in Leaflet via Python — pin the viewport so a basemap swap never strands users outside your data region
- Adding a Dark-Mode Basemap Toggle to a Leaflet Dashboard — the same swap discipline applied to a light/dark theme toggle
- Best Base Map Providers for High-Contrast Geo-Dashboards — choosing basemaps whose contrast keeps overlays legible after every switch