LayerTree Control for MapLibre
Posted by aselnigu on 10 October 2025 in English. Last updated on 12 October 2025.In Leaflet, I really enjoy using Leaflet.Control.Layers.Tree and find its possibilities amazing. That’s why I’ve named the control I describe here Layertree. For now, I want to start small and document the learning steps I’ve taken with vanilla JavaScript. Since I don’t have much experience yet, feedback or suggestions for improvement are very welcome! I hope this post will be helpful to others who are learning as well.
Layertree Control
Base Layers
A base layer is the lowest map layer that provides the general background or geographic context — things like:
- Streets, buildings, rivers, landscapes
- Satellite imagery or simple map drawings
It serves as the foundation on which other data can be displayed as overlays (e.g., markers, routes, thematic layers).
There is already an official control, maplibre-basemaps, which, however, only supports raster sources. I want to make it possible to use vector sources as basemaps as well.
Here’s a simple code example showing how to create a custom basemap layer switcher in MapLibre GL JS to switch between different base maps (layers) — whether vector or raster.

The HTML file loads MapLibre, the related CSS and JavaScript, and creates a <div> element for the map.
<!DOCTYPE html>
<html lang="de">
<head>
<title>Demo Notes 1</title>
<meta charset="utf-8">
<meta name="viewport" content="https://wiki.openstreetmap.org/wiki/Tag:width=device-width, https://wiki.openstreetmap.org/wiki/Tag:initial-scale=1">
<meta name="description" content="Demo Navication Control 1">
<link
href="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.css"
rel="stylesheet"
>
<script
src="https://unpkg.com/maplibre-gl@latest/dist/maplibre-gl.js"
></script>
<link rel="stylesheet" href="index.css">
<script type="module" src="index.js" defer></script>
</head>
<body>
<div id="map"></div>
</body>
</html>
The CSS file ensures that the map fills the entire screen and styles the layer selector .layer-tree-item so that it is usable on small screens with touch input.
body {
margin: 0;
padding: 0;
}
html,
body,
#map {
height: 100%;
}
/* layer tree*/
.layer-tree-item {
padding: 8px 12px;
}
The JavaScript file contains all the logic. It defines a class LayerTreeControl, which creates a form with radio buttons. Depending on the selection in the menu, _setLayer() switches the map style to the corresponding raster or vector layer. baselayers contains the available map sources.
const map = new maplibregl.Map({
container: "map",
center: [12, 50],
zoom: 6,
style: "https://tiles.versatiles.org/assets/styles/colorful/style.json",
});
class LayerTreeControl {
constructor(baselayers = []) {
this._baselayers = baselayers;
this._activeLayer = null;
}
onAdd(map) {
this._map = map;
this._container = document.createElement("div");
this._container.className =
"maplibregl-ctrl maplibregl-ctrl-group layer-tree-container";
this._form = this._buildForm();
this._container.appendChild(this._form);
return this._container;
}
_buildForm() {
const form = document.createElement("form");
form.className = "layer-tree-form";
const baseHeader = document.createElement("span");
baseHeader.textContent = "Basiskarten";
form.appendChild(baseHeader);
this._baselayers.forEach((layer, idx) => {
const id = `baselayer-${idx}`;
const item = this._createInputItem("radio", id, layer.name, "baselayer");
if (idx https://wiki.openstreetmap.org/wiki/Tag:=== 0) {
item.input.checked = true;
this._setLayer(layer);
}
item.input.addEventListener("change", () => {
if (item.input.checked) this._setLayer(layer);
});
form.append(item.container);
});
return form;
}
_createInputItem(type, id, labelText, name = null) {
const container = document.createElement("div");
container.className = "layer-tree-item";
const input = document.createElement("input");
input.type = type;
input.id = id;
input.value = labelText;
if (name) input.name = name;
const label = document.createElement("label");
label.setAttribute("for", id);
label.textContent = labelText;
container.append(input, label);
return { container, input, label };
}
_setLayer(layer) {
if (this._activeLayer https://wiki.openstreetmap.org/wiki/Tag:=== layer.name) return;
if (layer.type https://wiki.openstreetmap.org/wiki/Tag:=== "vector") {
this._map.setStyle(layer.url);
} else if (layer.type https://wiki.openstreetmap.org/wiki/Tag:=== "raster") {
this._map.setStyle({
version: 8,
sources: {
[layer.name]: {
type: "raster",
tiles: [layer.url],
tileSize: 256,
},
},
layers: [
{
id: layer.name,
source: layer.name,
type: "raster",
},
],
});
}
this._activeLayer = layer.name;
}
}
const baselayers = [
{
name: "osmde raster",
type: "raster",
url: "https://tile.openstreetmap.de/{z}/{x}/{y}.png",
},
{
name: "maplibre demotiles",
type: "vector",
url: "https://demotiles.maplibre.org/style.json",
},
{
name: "osm raster",
type: "raster",
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
},
{
name: "satellit",
type: "raster",
url: "https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
},
{
name: "osm vector",
type: "vector",
url: "https://pnorman.github.io/tilekiln-shortbread-demo/colorful.json",
},
];
map.on("load", () => {
const control = new LayerTreeControl(baselayers);
map.addControl(control, "top-right");
});
I kept the baselayers constant simple. Additional information such as attribution or minimum zoom levels can be added if needed.
The LayerTree control is added to the top-right corner after the map has loaded.
I placed the initialization inside the map.on("load", …) event handler:
map.on("load", () => {
const control = new LayerTreeControl(baselayers, overlays);
map.addControl(control, "top-right");
});
This ensures that the map style is fully loaded before calling setStyle() or addLayer(). It also makes sure overlays are loaded correctly and prevents race conditions — situations where multiple processes happen simultaneously or asynchronously.
map.on("load", …) is triggered once the style has finished loading, meaning the basemap (style, sources, and layers) has been completely initialized.
Overlay
I used data about OpenStreetMap user groups and communities from https://usergroups.openstreetmap.de/.

class LayerTreeControl {
+ constructor(baselayers = [], overlays = []) {
this._baselayers = baselayers;
+ this._overlays = overlays;
this._activeLayer = null;
+ this._activeOverlays = new Set();
+ this._controllers = new Map();
...
+ if (this._overlays.length > 0) {
+ form.appendChild(document.createElement("hr"));
+
+ const overlayHeader = document.createElement("span");
+ overlayHeader.textContent = "Overlays";
+ form.appendChild(overlayHeader);
+ }
+
+ this._overlays.forEach((overlay, idx) => {
+ const id = `overlay-${idx}`;
+ const item = this._createInputItem("checkbox", id, overlay.name);
+
+ item.input.addEventListener("change", () => {
+ this._setOverlay(overlay, true, item.input.checked);
+ });
+
+ form.append(item.container);
+ });
+
...
+ const restoreOverlays = () => {
+ this._activeOverlays.forEach((name) => {
+ const overlay = this._overlays.find((o) => o.name https://wiki.openstreetmap.org/wiki/Tag:=== name);
+ if (overlay) this._setOverlay(overlay, false, true);
+ });
+ };
+
if (layer.type https://wiki.openstreetmap.org/wiki/Tag:=== "vector") {
+ this._map.once("styledata", restoreOverlays);
this._map.setStyle(layer.url);
} else if (layer.type https://wiki.openstreetmap.org/wiki/Tag:=== "raster") {
+ this._map.once("styledata", restoreOverlays);
this._map.setStyle({
version: 8,
sources: {
...
+ _setOverlay(overlay, _updateHash = true, visible = true) {
+ if (!visible) {
+ this._removeOverlay(overlay);
+ return;
+ }
+
+ const prevController = this._controllers.get(overlay.name);
+ if (prevController) prevController.abort();
+
+ const controller = new AbortController();
+ this._controllers.set(overlay.name, controller);
+
+ fetch(overlay.url, { signal: controller.signal })
+ .then((res) => res.json())
+ .then((data) => {
+ if (!data.features?.length) return;
+
+ if (!this._controllers.has(overlay.name)) return;
+
+ const geomType = data.features[0].geometry.type;
+ const layerStyle = this._getLayerStyle(overlay.name, geomType);
+
+ if (this._map.getSource(overlay.name)) {
+ this._map.removeLayer(overlay.name);
+ this._map.removeSource(overlay.name);
+ }
+
+ this._map.addSource(overlay.name, { type: "geojson", data });
+ this._map.addLayer(layerStyle);
+
+ this._activeOverlays.add(overlay.name);
+ })
+ .catch((err) => {
+ if (err.name https://wiki.openstreetmap.org/wiki/Tag:!== "AbortError") console.error("Overlay-Fehler:", err);
+ });
+ }
+
+ _removeOverlay(overlay) {
+ if (this._map.getLayer(overlay.name)) this._map.removeLayer(overlay.name);
+ if (this._map.getSource(overlay.name)) this._map.removeSource(overlay.name);
+ this._activeOverlays.delete(overlay.name);
+
+ const controller = this._controllers.get(overlay.name);
+ if (controller) controller.abort();
+ this._controllers.delete(overlay.name);
+ }
+
+ _getLayerStyle(name, geomType) {
+ switch (geomType) {
+ case "Point":
+ return {
+ id: name,
+ type: "circle",
+ source: name,
+ paint: { "circle-radius": 6, "circle-color": "#007cbf" },
+ };
+ case "LineString":
+ case "MultiLineString":
+ return {
+ id: name,
+ type: "line",
+ source: name,
+ paint: { "line-color": "#007cbf", "line-width": 2 },
+ };
+ case "Polygon":
+ case "MultiPolygon":
+ return {
+ id: name,
+ type: "fill",
+ source: name,
+ paint: { "fill-color": "#007cbf", "fill-opacity": 0.4 },
+ };
+ default:
+ console.warn("Unbekannter Geometrietyp:", geomType);
+ return null;
+ }
+ }
The main difference between the first and this version of the code is that this version adds overlay functionality, while the first version only supported base maps:
overlaysarray for additional layersthis._activeOverlays→ stores the active overlay layersthis._controllers→ stores AbortController instances to cancel ongoing overlay fetches. Without this code, it could happen that the base map is switched while GeoJSON data is still loading, causing the data to be displayed or assigned incorrectly.
Hash
Next, I wanted to provide a permalink – a link that, when opened on another device, shows exactly the same map view, including location, zoom level, selected base layer, and active overlays.

When loading the form, I first check whether a layer or overlays are already defined in the URL:
const hashLayer = this._getLayerFromHash();
const hashOverlays = this._getOverlaysFromHash();
_getLayerFromHash()reads thelayerparameter from the URL hash._getOverlaysFromHash()reads the comma-separated list ofoverlays.
This allows me to restore the previous selection right during initialization:
this._baselayers.forEach((layer, idx) => {
const item = this._createInputItem("radio", `baselayer-${idx}`, layer.name, "baselayer");
if ((hashLayer && hashLayer https://wiki.openstreetmap.org/wiki/Tag:=== layer.name) || (!hashLayer && idx https://wiki.openstreetmap.org/wiki/Tag:=== 0)) {
item.input.checked = true;
this._setLayer(layer, false); // false = don’t update the hash again
}
});
For overlays:
this._overlays.forEach((overlay, idx) => {
const item = this._createInputItem("checkbox", `overlay-${idx}`, overlay.name);
if (hashOverlays.includes(overlay.name)) {
item.input.checked = true;
this._setOverlay(overlay, false, true); // false = don’t update the hash
}
});
Each time the user changes a layer or overlay, the hash is updated:
_updateLayerInHash(name) {
const params = this._getParams();
params.set("layer", name);
window.location.hash = params.toString();
}
_updateOverlaysInHash() {
const params = this._getParams();
if (this._activeOverlays.size) {
params.set("overlays", [...this._activeOverlays].join(","));
} else {
params.delete("overlays");
}
window.location.hash = params.toString();
}
Example code and demo
Technically, MapLibre plugins should implement both
onAddandonRemovemethods (see MapLibre GL JS API). In this case, I intentionally left outonRemove, since I only use the plugin myself and it’s unlikely to ever be removed once added.
German version of this text.
Discussion