Add a Map Legend to MapLibre GL JS
A MapLibre GL JS control plugin that shows a legend of the map features currently visible in the viewport.
The legend lists what is on screen right now: one entry per kind of feature (motorway, forest, country border, …) and, for named features such as places, one entry per type showing the most prominent name in the map’s own font. Entries come from the style’s maptoolkit:legend metadata, which Maptoolkit styles generated by @maptoolkit/style-family carry; custom layers can provide the same metadata.
Install
npm install @maptoolkit/maplibre-legend-control maplibre-glUsage
import * as maplibregl from "maplibre-gl";
import { LegendControl } from "@maptoolkit/maplibre-legend-control";
import "@maptoolkit/maplibre-legend-control/style.css";
const map = new maplibregl.Map({ container: "map", style, center, zoom });
map.addControl(new LegendControl());By default the control is placed in the bottom-left corner, above the Maptoolkit logo when it is added after the logo control (MapLibre stacks a bottom corner upwards); pass a position to addControl to change that:
map.addControl(new LegendControl(), "top-left");Without a bundler
The package is ESM-only (no UMD/CJS build). Loading it straight from a CDN via
a <script> tag works with an import map
to resolve the bare maplibre-gl specifier:
<link href="https://unpkg.com/maplibre-gl@^6.0.0/dist/maplibre-gl.css" rel="stylesheet" />
<link href="https://unpkg.com/@maptoolkit/maplibre-legend-control@^1.0.0/dist/maplibre-legend-control.css" rel="stylesheet" />
<script type="importmap">
{
"imports": {
"maplibre-gl": "https://unpkg.com/maplibre-gl@^6.0.0/dist/maplibre-gl.mjs"
}
}
</script>
<script type="module">
import * as maplibregl from "maplibre-gl";
import { LegendControl } from "https://unpkg.com/@maptoolkit/maplibre-legend-control@^1.0.0/dist/maplibre-legend-control.js";
const map = new maplibregl.Map({ container: "map", style, center, zoom });
map.addControl(new LegendControl());
</script>How it works
Maptoolkit styles generated by @maptoolkit/style-family carry legend metadata:
every layer has a tag in layer.metadata["maptoolkit:legend"] (which entry it
belongs to and in which role: main stroke, casing, texture, label …), and the
style root has a manifest with labels and order per entry and group. Whenever
the map becomes idle, the control reads the rendered features of the whole
viewport (map.queryRenderedFeatures()), whose layers come with per-feature
evaluated paint and layout, and builds:
- class entries — one per rendered main layer key (motorway, forest, country
border …), with a swatch stacked from the layer and its casing/blur/texture
layers using the evaluated colours, widths, gaps and dash patterns at the
current zoom (a casing’s gap is masked out, not painted, so a translucent
road still shows the route band beneath it). Lines are drawn as a curved SVG stroke stack whose bend
comes from one of four families, picked by how the feature runs on the map —
straight with sharp bends for aerial lifts, gentle for major roads, railways,
ferries and admin borders, wavier for minor roads, pistes, cycle routes and
waterways, winding for paths and contours — one of its shapes per entry,
taken from the layer’s position in the style so that layers drawn next to
each other never share a shape, stable across updates — as wide as on the
map up to 30 px (the row grows taller for them), beyond that scaled
together with their blur, with one scale for all lines of a group (the
widest decides), so a minor road stays narrower than a major one at every
zoom; the copies come from one and the same feature, ground level
preferred over bridge/tunnel duplicates whose shadows and casings stay out.
Route overlays (tag
overlay) show the whole stack of their feature — the hiking band together with the path it runs on. Of the rendered stretches the entry takes the one that shows it best: first one that carries no second overlay, so a cycle route is shown on a plain road rather than on a cycle lane unless every stretch in view shares its way, then one whose road is drawn at ground level, then the one with the fullest stack. Only where every stretch in view runs over a bridge or through a tunnel are the crossing copies stacked — otherwise the band would stand alone although the map draws a road under it. Fills are polygons in one of three shape families, picked by what the layer depicts — smooth, gently wavy outlines that still tend to the rectangle for natural areas and waters, straight-edged parcels for landuse, orthogonal footprints for buildings — one of ten shapes per family, chosen per entry like the line bends. They carry the colour, the sprite patterns of their stack,fill-outline-coloras a hairline and the crisp line layers as an inner border along the shape (an intermittent lake keeps its dashed shoreline), while shadow and other blurred strokes lie below as a soft halo along the edge, as faint as on the map (a building’s shadow does not frame it). SDF sprite icons are recoloured with theiricon-color; - instance entries — for symbol layers, which are a map symbol of their own
and cannot be drawn into a line or a polygon: places, waters and POIs give one
entry per type, a road shield, a route name, a difficulty grade or a one-way
arrow one entry per layer. Each shows the most prominent feature among those
whose label lies entirely inside the map minus a 5 % edge buffer (lowest rank,
then the one closest to the centre) as the map draws it: the name in the map’s
own font, size, colour and halo, and where there is an icon the icon with the
name placed by
text-anchor/text-offset— text below the icon, beside it, or over it. A shield (icon-text-fit) carries its icon behind the name, padded byicon-text-fit-padding: a stretchable sprite keeps the frame around itscontentbox and grows only in the middle (border-image), a plain one is stretched as a whole. It therefore grows with the number it carries, and an uneven padding shifts the number inside it, as on the map. Names wrap at spaces, hyphens and slashes only; a longer one widens the column — up to the name cap (maxNameWidth, by default the smaller of half the room the panel has and 260 px): a word longer than that ends in an ellipsis, a name set along a line is shortened to fit. A symbol whose tag names the main layers it sits on (anchors: a shield on its route, a river’s name on its waterway) is drawn on that feature’s swatch when the feature is in view — the very stack of the feature’s own row, in its shape, stretched under a wider name with the strokes keeping their width, and reaching a little beyond it (--legend-control-symbol-reach,--legend-control-symbol-reach-y): a line runs on past both ends of the name, a surface encloses it on every side. A name the map sets along its line (symbol-placementline with map rotation — a river’s name, a route’s) follows the bend here too, as SVG text on the line’s own path, centred, in the map font with its halo, shifted off the line bytext-offset; the line is widened to the measured name plus the reach, the tighter bend families give way to a gentler one, as the map labels only its softer curves. The name is measured again once the webfonts have loaded. A one-way arrow turns into the line’s direction, a shield (viewport alignment) stays upright. Of several anchors in view the one that drew the same feature wins, then the entry the symbol’s own values pick, then the first; with none in view the symbol stands alone. A label the style attaches to its feature instead (tagattachesTo, e.g. the contour elevations) has no row of its own: its text is set on the feature’s own swatch line the same way — one contour line with its number, not a line and a labelled line. - A document behind the row. A manifest entry may carry a
link(a URL, or one per language like the label): the row then ends in a small circled “i” at its top right that opens the document in a new tab — the SAC hiking scale and the via ferrata grades link to the clubs’ explanations. Its tooltip is the locale stringLegendControl.Info.
The layer tags are read from the style sheet MapLibre holds
(map.style.stylesheet), not from the rendered layers: a style set with
diff: true does not carry layer metadata over to layers that exist in both
styles, so after a style switch the rendered features could still carry the
previous style’s tags — a base line attached to lifts the old style did not
draw would stay out of the new lift’s row until a reload.
A hairline separates the groups, a trace of the text colour so it stays faint on
whatever ground the style paints (--legend-control-rule); the group heading is
centred over both columns. The description on the right is left-aligned, a
step smaller than the panel text (--legend-control-label-font-size) and wraps
beyond --legend-control-label-max-width (12em, about two words of a typical label) instead of widening the panel. Every row follows one rule: left what the map
shows, right the explanation —
a stroke stack or fill for a class entry, the symbol itself for an instance
entry; the manifest label (or the humanized key) on the right.
The list is one grid across all groups, so the left column is as wide as the
widest symbol and every block sits on one centre axis; text-justify only
aligns the lines inside a block (a peak keeps its elevation left-aligned under
its name). Rows are as tall as their content, so a host that gives the list a
fixed height (e.g. a flex layout) gets a scrolling list, never squeezed rows.
The reverse also happens: one layer can paint a few property values
differently, and its tag then gives those values their own keys
(keyByValue — a pedestrian zone is not a minor road even though one layer
draws both). Similar-looking types can share one row: a manifest entry may list the keys
it stands for (place:village for place:hamlet, place:farm, …), and the
control maps rendered keys to it before building the legend. The Maptoolkit
styles ship such merges for places (small settlements, districts, islands,
parks, landforms, natural areas); styles override them like every other
manifest field. Custom layers take part when they carry the same tag; layers
without a tag are ignored. The contract is documented in
Map legend in the Maptoolkit docs.
Options
| Option | Type | Default | Description |
|---|---|---|---|
collapsed | boolean | true with a button, false with toggle: false | Start hidden; the button or open() shows the panel, updates are deferred until then. Every button variant starts closed; without a button the host opens the control by mounting it. |
toggle | boolean | true | Render a button that shows and hides the panel; in a map corner the panel opens below it (top corners) or above it (bottom corners). Hosts with their own trigger (a toolbar button) set false. |
button | "icon-text" | "icon" | "style-control" | "attribution" | "icon-text" | What the toggle button shows: an icon of a legend row (Material Symbols “event_list”, turned so the swatches stand left) followed by the word for “legend” in the control’s language (LegendControl.Label), or the icon alone. "style-control" puts the legend into the style control’s panel instead (see Inside the style control), "attribution" puts the word on MapLibre’s attribution line (see On the attribution line). |
styleControl | StyleControl | – | The @maptoolkit/maplibre-style-control instance that hosts the legend with button: "style-control". |
language | string | <html lang>, else browser language | Language of the manifest labels (de, en, …); falls back to English, then to the humanized key. |
edgeBuffer | number | 0.05 | Only labels whose rendered box lies entirely inside the map minus this fraction per side are listed; 0 lists every rendered label. |
groups | string[] | all | Restrict the legend to these groups (road, water, nature, border, building, relief, place, poi). |
updateDelay | number | 100 | Debounce in ms between the map’s idle event and the update. |
maxHeightRatio | number | 0.6 | Maximum panel height as a fraction of the map’s height; the list scrolls beyond it. The width follows the content so no label is clipped, up to the map’s width. |
maxNameWidth | { fraction?, px? } | false | { fraction: 0.5, px: 260 } | How wide a name in the map font may grow: the smaller of px and fraction of the room the panel has (the map’s width less the panel’s offset from the far edge). A word longer than that is cut with an ellipsis, a name set along a line is shortened to fit; below the cap the map’s own line wrapping applies. false lifts the cap. |
minOpacity | number | 0.1 | Rows none of whose layers reaches this opacity are left out — a fill fading in between zooms, a stroke at 0.02. A layer’s opacity is its *-opacity times the alpha of its colour; if any layer of the row, main or supporting, reaches it, the row shows the whole stack as usual. 0 keeps every row. |
background | "auto" | string | "auto" | Panel background: the style’s background layer colour at the current zoom (fallback hsl(90, 23%, 95%)), or a fixed CSS colour. Text switches to light on dark backgrounds. |
fonts | string | false | from the style | Stylesheet of the map’s web fonts, linked into the page once so names appear in the map’s typeface. Maptoolkit styles carry its URL in their legend manifest (fonts.css); a string overrides it, false links nothing. |
Inside the style control
With button: "style-control" there is no legend button in the map corner. The
legend becomes a row at the foot of the style control’s panel, below the styles
and behind a hairline: the legend icon, the word for “legend” and a chevron. The
row closes the style panel and opens the legend where the style panel was, with
the same motion. A click on the style tile brings the style panel back in the
legend’s place; with the style panel open the tile closes it, as before. The tile
stays lifted while the legend is open; the panel’s ✕ and Escape close the
legend and move the focus to the tile. Only one of the two panels is open at a time. On maps up
to 768px wide the legend opens above or below the tile instead, so the tile
stays in reach.
import { StyleControl } from "@maptoolkit/maplibre-style-control";
const styleControl = new StyleControl({ styles, active: "Summer" });
map.addControl(styleControl); // first: the legend moves into its panel
map.addControl(new LegendControl({ button: "style-control", styleControl }));The style control needs no changes and the package is no dependency. The legend
relies on its public close(), its container (_container) and its class names
(maplibre-style-control, -current, -groups, -active); the row takes the
style panel’s --style-control-* tokens. Without a style control on the map,
passed as styleControl and added before the legend, the legend warns and shows
its "icon-text" button.
On the attribution line
With button: "attribution" the word for “legend” stands in bold on MapLibre’s
attribution line, before the attribution and set off by a separator:
Legend | MapLibre | © Maptoolkit © OSM. It takes the bar’s look: part of
the full bar (attributionControl: { compact: false } on a wide map) or part of
the expanded compact pill (MapLibre’s default). Once the bar has collapsed to
its ⓘ, as it does after the first drag, the word is gone and the legend opens
only after the ⓘ has expanded the bar again; a panel that is open stays open
while the map moves. The panel opens above the bar, 10px from the map’s edge.
map.addControl(new LegendControl({ button: "attribution" }));MapLibre’s attribution control is not changed: the word is a control of its
own on the bar’s line, and the legend follows the bar’s classes
(maplibregl-ctrl-attrib, maplibregl-compact, maplibregl-compact-show,
maplibregl-attrib-empty). Without an attribution control on the map
(attributionControl: false) the legend warns and shows its "icon-text"
button.
Methods
const control = new LegendControl();
map.addControl(control);
control.update(); // re-read the viewport now
control.open(); // show the panel
control.close(); // hide it
control.toggle();
control.getModel(); // { groups: [{ id, label, entries: [{ key, kind, label, name?, swatch }] }] }
The model builder and the font mapping are exported for integrations that
render their own legend: buildLegendModel(...), parseFontStack(...),
fontStackToCss(...).
Fonts
Instance names are set in the map’s font stack (text-font), mapped to CSS
("Rosario Bold Italic" → font-family: "Rosario"; font-weight: 700; font-style: italic, "Alegreya Small Caps Bold" → "Alegreya SC"). The web
fonts come from the style: a Maptoolkit style’s legend manifest names the
stylesheet that serves its typefaces (fonts.css, on static.maptoolkit.net),
and the control links it into the page once — the browser then fetches only
the faces the legend actually draws. Pass fonts: "<url>" for a stylesheet
of your own, or fonts: false when the page already provides the fonts. Until
a face arrives (or if none does) the text shows in a generic family matching
the typeface — serif for Alegreya or Epunda Slab, cursive for Lobster,
monospace for SUSE Mono, sans-serif otherwise.
Localization
The control reads its UI strings from the map’s locale table, like MapLibre’s
built-in controls, and fills that table on onAdd with the strings of its
language — the languages of the Maptoolkit map maker are covered: English,
German, Spanish, Italian, French, Hungarian, Czech, Polish, Chinese, Japanese,
Korean, Hindi and Arabic (LEGEND_LOCALES, legendLocaleFor(language)). An
entry the page already set wins, so a single string is overridden like this:
const map = new maplibregl.Map({
container: "map",
style,
locale: { "LegendControl.Label": "Zeichenerklärung" },
});| Key | Used for | English |
|---|---|---|
LegendControl.Label | The toggle button’s text (button: "icon-text") | Legend |
LegendControl.Title | The panel’s accessible name (there is no visible header) | Legend |
LegendControl.Toggle | Tooltip and accessible name of the toggle button | Show or hide the legend |
LegendControl.Empty | Shown when nothing tagged is in view | Nothing to show in this view |
LegendControl.Info | Tooltip of a row’s info button | More about this |
LegendControl.Close | Tooltip and accessible name of the panel’s ✕ | Close the legend |
Styling
The panel takes the map’s ground colour: the style’s background layer at the
current zoom (--legend-control-bg-color, fallback hsl(90, 23%, 95%)), so
names and swatches sit on the same ground as on the map; on a dark background
the text colours switch to light (.maplibre-legend-control-dark). Pass
background: "<css colour>" to fix it instead. The toggle button wears the
Maptoolkit control skin of maplibre-style-control: as tall as MapLibre’s
buttons (--legend-control-toggle-size, 29px), rounded (--legend-control-radius),
with a soft shadow; it lifts on hover (only where the pointer can hover) and
stays lifted and tinted while the panel is open (--legend-control-toggle-bg-active,
--legend-control-toggle-color-active). Its colours are its own
(--legend-control-toggle-bg, --legend-control-toggle-color), so a dark style
does not turn its text white. In a map corner the panel opens below the button
in the top corners and above it in the bottom corners, aligned with the button’s
outer edge, so the button keeps its place with or without the word — overlaying
the neighbouring controls while open, like maplibre-style-control. The panel
fades in and slides 8px from the button’s side (--legend-control-duration;
only the fade under prefers-reduced-motion). An ✕ at the panel’s top right
(LegendControl.Close, in the panel’s text colours) and Escape close it and
return the focus to the button; with toggle: false the host owns the open
state and there is no ✕. The list scrolls with a thin
native scrollbar (scrollbar-width: thin) whose thumb is coloured for the
panel’s ground (--legend-control-scrollbar-thumb, light on dark backgrounds).
Appearance is controlled via CSS custom properties on .maplibre-legend-control, defined in style.css. Override them in your own stylesheet to theme the control:
.maplibre-legend-control {
--legend-control-radius: 4px;
--legend-control-color-primary: #0074d9;
}See src/style.css for the full list of --legend-control-* variables.
Development
npm install
npm run dev # demo at http://localhost:5173
npm test
npm run lint && npm run typecheck && npm run buildThe demo loads the published Maptoolkit style summer.json from styles.maptoolkit.org, which carries the legend metadata. To develop against another style (e.g. one from a local styleeditor), pass its URL: http://localhost:5173/?style=<style-url>.
License
maplibre-legend-control is open-source under the BSD 3-Clause License.
Hosted demo
demos.maptoolkit.net/legend/index.html
is a plain page (demo/hosted/index.html) that loads
MapLibre, this control and the style and logo controls — as published on npm —
from jsDelivr through an import map, and the published Maptoolkit styles from
styles.maptoolkit.org, which carry the legend metadata. It shows the four button
placements at once. demo/hosted/upload.sh puts the page into the demos bucket
(Hetzner Object Storage) with curl’s SigV4 signing; it needs
DEMOS_S3_KEY/DEMOS_S3_SECRET.