Skip to content

HTML pages: the <ml-map> element

The <ml-map> element draws a map document on an HTML page. It is a web component (a custom HTML element), so it works in any page: load it from a CDN, point its src at a YAML file, and give it a height. There is no JavaScript to write.

This page is for plain HTML pages. On an Astro site, use the Astro components; in React, Vue or Svelte, see React & other frameworks.

  1. Create index.html

    <!DOCTYPE html>
    <html lang="en">
    <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <title>My map</title>
    <link
    rel="stylesheet"
    href="https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.css"
    />
    <!-- Tell the browser where to find maplibre-gl -->
    <script type="importmap">
    { "imports": { "maplibre-gl": "https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.mjs" } }
    </script>
    <!-- Registers <ml-map> -->
    <script
    type="module"
    src="https://unpkg.com/@maplibre-yaml/core@0.10/register.js"
    ></script>
    </head>
    <body>
    <ml-map src="map.yaml" style="display: block; height: 400px;"></ml-map>
    </body>
    </html>
  2. Create map.yaml next to it

    type: map
    id: quakes
    config:
    mapStyle: "https://tiles.openfreemap.org/styles/liberty"
    center: [-120, 37]
    zoom: 4
    layers:
    - id: quakes
    type: circle
    source:
    type: geojson
    url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_week.geojson"
    paint:
    circle-radius: 5
    circle-color: "#dc2626"
  3. Serve the folder and open it

    Terminal window
    npx serve .

    Open the address it prints (for example http://localhost:3000). The map shows California with the past week’s earthquakes as red dots.

Open the page from a web server, not by double-clicking it: a page opened as file:// can’t fetch map.yaml, and the map shows “Open this page through a local web server” instead. The Quick start walks through the same page step by step, and Your first map, explained explains each line of the YAML.

src is the address of the map document, relative to the page. Keeping the YAML in its own file keeps the page short, and lets you check the file with npx maplibre-yaml@latest validate map.yaml before you publish.

<ml-map src="maps/city.yaml" style="display: block; height: 400px;"></ml-map>

Paths inside the YAML (url: "data/points.geojson") resolve against the page, not the YAML file (Loading data › How paths are read). Changing src fetches the new document and applies it to the running map in place (Change the map after it loads); reload() fetches the same one again and applies what changed.

Give every <ml-map> display: block and a height. A custom element is inline and zero-height by default, so without them the map is invisible, and the element shows a note asking for a height in its place.

On a GitHub Pages project site (https://<user>.github.io/<repo>/), add base="/<repo>/" and leave the YAML as it is. src and every path to a file of your own then load under the subpath. Which paths it covers and how to preview it locally: Publishing & subpaths.

<ml-map src="maps/city.yaml" base="/<repo>/" style="display: block; height: 400px;"></ml-map>

Children with a slot attribute go in a corner of the map: top-left, top-right, bottom-left or bottom-right, stacked with the built-in legend and params panel. A slot="legend" child replaces the built-in legend. Buttons, captions and credits sit over the map without any positioning CSS:

<ml-map src="map.yaml" style="display: block; height: 400px;">
<div slot="top-right" class="my-controls">
<button data-action="zoom-in">+</button>
<button data-action="reset">Reset</button>
</div>
<p slot="bottom-left">Data: USGS</p>
</ml-map>
<script type="module">
const el = document.querySelector("ml-map");
const home = await el.mapReady().then((map) => ({
center: map.getCenter(),
zoom: map.getZoom(),
}));
document.querySelector(".my-controls").addEventListener("click", (e) => {
const map = el.getMap(); // read at click time: a basemap change makes a new map
const action = e.target.closest("[data-action]")?.dataset.action;
if (action === "zoom-in") map.zoomIn();
if (action === "reset") map.jumpTo(home);
});
</script>

Slot children are shown once the map loads, and stay through reload() and a new document. How they stack and behave is under <ml-map> slots.

mapReady() resolves with the MapLibre map once the document has loaded (at once if it already has), and rejects if the document fails. From there, anything MapLibre can do works:

<script type="module">
const el = document.querySelector("ml-map");
const map = await el.mapReady();
map.flyTo({ center: [-122.42, 37.77], zoom: 9 });
map.on("moveend", () => console.log("now at", map.getCenter()));
</script>

Layers you add with MapLibre’s own map.addLayer() are yours to manage; keep their ids different from the document’s. A new document never moves or removes them. getMap() returns the map without waiting (null before it loads) and keeps returning the same map through document changes (only a basemap change builds a new one), and getRenderer() returns the MapRenderer behind the element.

Assign a different document to config (or change src) and the running map changes in place: layers and sources that differ are added, removed or updated by id, the legend and panel follow, and the camera moves only if the document’s camera changed. The map object, your listeners and your own layers stay. update() does the same and resolves with what happened:

<script type="module">
const el = document.querySelector("ml-map");
await el.mapReady();
el.addEventListener("ml-map:update-applied", ({ detail }) => {
console.log("changed", detail.summary); // added, removed, propertiesSet, ...
});
el.addEventListener("ml-map:update-rejected", ({ detail }) => {
console.warn(detail.kind, detail.errors); // the map is as it was
});
const doc = structuredClone(el.getDocument()); // the document the map shows
doc.layers[0].paint["circle-color"] = "#2563eb";
const { outcome } = await el.update(doc); // "applied"
</script>

An invalid document is refused: ml-map:update-rejected carries the diagnostics, the map stays as it was, and the notice shows a hint while you develop. A changed mapStyle rebuilds the map at the current camera and fires ml-map:load again; everything else applies in place.

For a page whose readers pan the map themselves (a dashboard whose data refreshes), add camera="host" so a new document never moves the view:

<ml-map src="map.yaml" camera="host" style="display: block; height: 400px;"></ml-map>

A document’s state: values are read with getState() and written with setState({ scenario: "flat" }); each write fires ml-map:state-change (State and parameters). The rules in full, your own layers included, are under Updating the document.

The element dispatches bubbling ml-map:* events: when the map loads, when a layer’s data arrives or refreshes, when a feature is clicked or hovered, and more. Each carries its data on event.detail:

<script type="module">
const el = document.querySelector("ml-map");
el.addEventListener("ml-map:layer-click", (event) => {
const { layerId, feature } = event.detail;
console.log(layerId, feature.properties.title);
});
</script>

Every event, with its detail, is under <ml-map> events.

<ml-map> shows problems on the page, not only in the console: an error card in place of a map that can’t render, a notice over a map when part of it didn’t load, and author hints in that notice while you develop (localhost), or anywhere with the debug attribute. quiet hides the notice on a finished site; the error card always shows. What each one means, and the common fixes, are in Troubleshooting. A page script can read the same problems from ml-map:error:

el.addEventListener("ml-map:error", (event) => {
// A document that can't load or validate: errors, each { path, message }.
// A failure while the map runs: error, with fatal true or false.
console.error(event.detail.errors ?? event.detail.error);
});

Each <ml-map> is independent and loads its own document. The page needs the import map and register.js only once:

<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">
<ml-map src="map-1.yaml" style="display: block; height: 400px;"></ml-map>
<ml-map src="map-2.yaml" style="display: block; height: 400px;"></ml-map>
</div>

The same register.js also defines <ml-scrollytelling>, which shows a type: scrollytelling story file: a sticky map with chapters scrolling over it. See Story maps in plain HTML.

Every attribute (src, config, base, camera, debug, quiet), property, method, event, slot and styling hook of <ml-map> is in the <ml-map> reference. What a map document can contain is in the YAML reference.

Most problems look the same whichever way the map is on the page; they are in Troubleshooting: the map has no height, <ml-map> isn’t defined, an error card instead of the map, and the basemap draws but your layer doesn’t.

An ml-map:load listener added after the map has loaded never runs: the event has already happened. Use await el.mapReady() instead, which resolves whether the map loaded before or after you ask. If the map never loads, an ml-map:error listener tells you why.

src with a YAML file, above, is the recommended way. Use these when it doesn’t fit.

For a page that must be one file, put the YAML in a <script type="text/yaml"> inside the element. Start the YAML on the line after the opening tag, and keep every line at the same depth relative to the first (indenting the whole block to match your HTML is fine):

<ml-map style="display: block; height: 400px;">
<script type="text/yaml">
type: map
id: inline-map
config:
mapStyle: "https://tiles.openfreemap.org/styles/liberty"
center: [-74.006, 40.7128]
zoom: 12
</script>
</ml-map>

<script type="application/yaml"> works the same way, and <script type="application/json"> takes the document written as JSON:

<ml-map style="display: block; height: 400px;">
<script type="application/json">
{ "type": "map", "id": "inline-json",
"config": { "mapStyle": "https://tiles.openfreemap.org/styles/liberty",
"center": [-74.006, 40.7128], "zoom": 12 } }
</script>
</ml-map>

When your code builds the document (from a form, or an API response), set the config property to the object, or the config attribute to a JSON string:

<ml-map id="dynamic-map" style="display: block; height: 400px;"></ml-map>
<script type="module">
const doc = {
type: "map",
id: "dynamic",
config: {
mapStyle: "https://tiles.openfreemap.org/styles/liberty",
center: [-74.006, 40.7128],
zoom: 12,
},
};
document.getElementById("dynamic-map").config = doc;
// or: el.setAttribute("config", JSON.stringify(doc));
</script>

The document is validated like a YAML file; an invalid first document shows the error card. A changed config is applied to the running map in place: the map and its camera stay, and only what differs changes (Change the map after it loads); an invalid one is refused with ml-map:update-rejected and the map kept. One equal to the document already shown does nothing. A config assigned right after the element upgrades renders without a passing “No configuration provided” error.

Install @maplibre-yaml/core and maplibre-gl, import @maplibre-yaml/core/register and MapLibre’s CSS once in your entry file, and point maplibre-gl 6 at its worker: Installation: using a bundler. Put the YAML and data files in the bundler’s public/ folder. The element then works in your HTML as above.

React, Vue, Svelte and other frameworks render <ml-map> like any other tag. How each passes config, listens for events and types the element is on React & other frameworks.

A format v2 document (version: 2, with style: and runtime:) works in every way shown on this page and draws the same map as its format v1 twin.

Your own JavaScript instead of the element

Section titled “Your own JavaScript instead of the element”

To parse and render a document from your own code, into a <div> you control, see the JavaScript API.

Loading from a CDN, versions in CDN URLs and supported maplibre-gl versions are in Installation & versions. Hosting maplibre-yaml’s own files is in Publishing & subpaths.

How attribution text is sanitized is in the <ml-map> reference. Why click.emit does nothing under <ml-map> is under Host events.

Polling follows the document’s refreshInterval (Live data); each refresh fires ml-map:layer-data-loaded (events).