Skip to content

Add a Map Legend to MapLibre GL JS

License Version Downloads

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-gl

Usage

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-color as 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 their icon-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 by icon-text-fit-padding: a stretchable sprite keeps the frame around its content box 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-placement line 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 by text-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 (tag attachesTo, 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 string LegendControl.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

OptionTypeDefaultDescription
collapsedbooleantrue with a button, false with toggle: falseStart 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.
togglebooleantrueRender 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).
styleControlStyleControl–The @maptoolkit/maplibre-style-control instance that hosts the legend with button: "style-control".
languagestring<html lang>, else browser languageLanguage of the manifest labels (de, en, …); falls back to English, then to the humanized key.
edgeBuffernumber0.05Only labels whose rendered box lies entirely inside the map minus this fraction per side are listed; 0 lists every rendered label.
groupsstring[]allRestrict the legend to these groups (road, water, nature, border, building, relief, place, poi).
updateDelaynumber100Debounce in ms between the map’s idle event and the update.
maxHeightRationumber0.6Maximum 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.
minOpacitynumber0.1Rows 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.
fontsstring | falsefrom the styleStylesheet 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" },
});
KeyUsed forEnglish
LegendControl.LabelThe toggle button’s text (button: "icon-text")Legend
LegendControl.TitleThe panel’s accessible name (there is no visible header)Legend
LegendControl.ToggleTooltip and accessible name of the toggle buttonShow or hide the legend
LegendControl.EmptyShown when nothing tagged is in viewNothing to show in this view
LegendControl.InfoTooltip of a row’s info buttonMore about this
LegendControl.CloseTooltip 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 build

The 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.