Web Components
The <ml-map> web component is the simplest way to add maps to any HTML page. No JavaScript required for basic usage - just HTML and a YAML configuration file.
Quick Start
Section titled “Quick Start”Get a map on your page in 3 steps.
-
Add the scripts and styles
<head><!-- MapLibre GL CSS --><linkhref="https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.css"rel="stylesheet"/><!-- 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><!-- Register the web component --><scripttype="module"src="https://unpkg.com/@maplibre-yaml/core/register.js"></script></head> -
Create a YAML configuration file
Create
map.yamlin your project:type: mapid: quick-mapconfig:center: [-74.006, 40.7128]zoom: 12mapStyle: "https://demotiles.maplibre.org/style.json"layers:- id: markertype: circlesource:type: geojsondata:type: FeatureCollectionfeatures:- type: Featuregeometry:type: Pointcoordinates: [-74.006, 40.7128]properties:name: "New York"paint:circle-radius: 10circle-color: "#3b82f6" -
Add the map component
<ml-map src="map.yaml" style="width: 100%; height: 400px; display: block;"></ml-map> -
View in browser
Serve your files with any web server (e.g.,
npx serve .) and open in browser!
Installation Options
Section titled “Installation Options”CDN (Recommended for Quick Start)
Section titled “CDN (Recommended for Quick Start)”The import map is required: it tells the browser where to find maplibre-gl, which the register bundle keeps external.
<script type="importmap"> { "imports": { "maplibre-gl": "https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.mjs" } }</script><script type="module" src="https://unpkg.com/@maplibre-yaml/core/register.js"></script>npm (With Build Tools)
Section titled “npm (With Build Tools)”npm install @maplibre-yaml/core maplibre-gl@^6// In your JavaScript entry pointimport "@maplibre-yaml/core/register";import "maplibre-gl/dist/maplibre-gl.css";Configuration Methods
Section titled “Configuration Methods”There are three ways to configure <ml-map>, each suited for different use cases.
Method 1: External YAML File (Recommended)
Section titled “Method 1: External YAML File (Recommended)”The simplest and cleanest approach - keep your configuration in a separate YAML file:
<ml-map src="/maps/my-map.yaml" style="height: 400px; display: block;"></ml-map>my-map.yaml:
type: mapid: my-mapconfig: center: [-74.006, 40.7128] zoom: 12 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: points type: circle source: type: geojson url: "https://example.com/data.geojson" paint: circle-radius: 8 circle-color: "#3b82f6"Why this is best:
- ✅ Clean separation of configuration and markup
- ✅ No JavaScript required
- ✅ YAML indentation is never an issue
- ✅ Easy to edit without touching HTML
- ✅ Files can be validated independently
- ✅ Works in all frameworks
Method 2: Inline YAML (Script Tag)
Section titled “Method 2: Inline YAML (Script Tag)”For self-contained examples or documentation:
<ml-map style="height: 400px; display: block;"> <script type="text/yaml">type: mapid: inline-mapconfig: center: [-74.006, 40.7128] zoom: 12 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: marker type: circle source: type: geojson data: type: FeatureCollection features: - type: Feature geometry: type: Point coordinates: [-74.006, 40.7128] properties: name: "New York" paint: circle-radius: 10 circle-color: "#3b82f6" </script></ml-map>Why this works:
- Browser doesn’t process
<script type="text/yaml">content - Whitespace and indentation are preserved exactly
- Works in plain HTML, Astro, MDX and Svelte templates; in React, pass the
YAML as a string child (
<script type="text/yaml">{yaml}</script>) - Not in Vue templates: Vue’s compiler rejects
<script>tags in a template — usesrcorconfigthere (see Framework Compatibility) - Good for examples that need to be self-contained
Method 3: JSON Attribute (Programmatic)
Section titled “Method 3: JSON Attribute (Programmatic)”For dynamic or generated configurations:
<ml-map id="dynamic-map" style="height: 400px; display: block;"></ml-map>
<script type="importmap"> { "imports": { "maplibre-gl": "https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.mjs" } }</script><script type="module"> import "https://unpkg.com/@maplibre-yaml/core/register.js";
const config = { type: "map", id: "programmatic", config: { center: [-74.006, 40.7128], zoom: 12, mapStyle: "https://demotiles.maplibre.org/style.json", }, layers: [], };
// Set as property (object) document.getElementById("dynamic-map").config = config;
// Or as attribute (JSON string) // document.getElementById("dynamic-map").setAttribute("config", JSON.stringify(config));</script>When to use this:
- Generating config from user input
- Loading config from an API
- Building configs programmatically
- Framework-based dynamic rendering
Component Lifecycle
Section titled “Component Lifecycle”Initialization Flow
Section titled “Initialization Flow”1. Element added to DOM2. connectedCallback() fires3. Config attribute/property read4. Config validated against schema5. MapLibre GL map created6. Layers added7. 'ml-map:load' event dispatchedLifecycle Events
Section titled “Lifecycle Events”const mapEl = document.querySelector("ml-map");
// Map loaded and readymapEl.addEventListener("ml-map:load", (event) => { console.log("Map ready!");});
// Error during initialization or runtime. Configuration/validation failures// carry `errors` (an array of { path, message }); runtime failures carry `error`.mapEl.addEventListener("ml-map:error", (event) => { console.error("Map error:", event.detail.error ?? event.detail.errors);});
// Layer data loadedmapEl.addEventListener("ml-map:layer-data-loaded", (event) => { const { layerId, featureCount } = event.detail; console.log(`${layerId}: ${featureCount} features`);});
// Layer data loadingmapEl.addEventListener("ml-map:layer-data-loading", (event) => { console.log(`Loading ${event.detail.layerId}...`);});
// Layer data errormapEl.addEventListener("ml-map:layer-data-error", (event) => { console.error(`${event.detail.layerId} error:`, event.detail.error);});Event reference
Section titled “Event reference”Every event <ml-map> dispatches (all bubble; payload on event.detail):
| Event | Detail | Fires when |
|---|---|---|
ml-map:loading | { url } | A src document fetch begins |
ml-map:load | {} | The document is fully loaded — basemap and every layer |
ml-map:error | { error, fatal } or { errors } | Initialization or runtime failure (fatal: true aborts the document); a document that fails to parse or validate carries errors — the structured { path, message }[] the error card shows — instead |
ml-map:layer-added | { layerId } | A layer was added to the map |
ml-map:layer-removed | { layerId } | A layer was removed |
ml-map:layer-data-loading | { layerId } | A layer’s data fetch begins |
ml-map:layer-data-loaded | { layerId, featureCount } | A layer’s data landed |
ml-map:layer-data-error | { layerId, error } | A layer’s data fetch failed |
ml-map:layer-click | { layerId, feature, lngLat } | A feature on an interactive layer was clicked |
ml-map:layer-hover | { layerId, feature, lngLat } | A feature on an interactive layer was hovered |
ml-map:markers-added | { count } | The markers: pins were added |
ml-map:marker-click | { index, at } | A standalone marker was clicked |
ml-map:marker-icon-error | { index, icon } | A marker icon: failed (unsafe scheme or load error); the default pin was substituted |
ml-map:image-error | { name, url } | An images: entry failed (unsafe scheme, CORS, load or register error) |
ml-map:parameter-change | { key, value } | A params-panel control wrote a state key |
ml-map:layer-visibility | { layerId, visible } | A params-panel checkbox toggled a layer |
ml-map:camera-fit | { source, bounds } | The fitTo camera framed its source’s data (once, on the first data load for fetched sources) |
Slots: your own chrome over the map
Section titled “Slots: your own chrome over the map”Author markup placed inside <ml-map> with a slot attribute rides the
element’s corner system — the same one the legend and the params panel use —
so page chrome (buttons, captions, a custom legend) sits over the map without
hand-rolled absolute positioning:
<ml-map src="/configs/map.yaml"> <div slot="top-right" class="my-controls"> <button data-action="zoom-in">+</button> <button data-action="reset">⟲</button> </div> <div slot="bottom-left">Data: city open data portal</div></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 — reload() 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 | Placement |
|---|---|
top-left, top-right, bottom-left, bottom-right | Mounted into that corner of the map |
legend | Replaces the built-in legend, at the document’s legend.position (default top-left) |
How it behaves:
- Stacking, not overlap. Pieces sharing a corner stack in registration
order: the built-in legend first, then the params panel, then slot
children in document order. Bottom corners grow upward. A corner also
steps past a MapLibre control (e.g.
navigation) already in it. - Mounted on load. Slot children are hidden until the map’s
load, then moved into their corner. They stay the same elements, so event listeners you attached keep working. - They survive the map.
reload(), a newconfig/src, and an error card all tear the map down but park slot children back on the element; the next successful render mounts them again. Slot children added after load are picked up on the next render (reload()). - Pointer events. Empty corner areas pass gestures through to the map; your slot content receives clicks normally.
- An unknown slot name logs a warning and the child is left where it is.
Only children with a recognized slot are moved. Other children you place
inside <ml-map> (including the inline <script type="text/yaml">) are left
alone: the element removes only what it created itself — its map container
and its error card.
The element’s methods, properties, and this event list (from its @fires
JSDoc) are also in the generated MLMap API reference.
Accessing the MapLibre Instance
Section titled “Accessing the MapLibre Instance”For advanced customization, access the underlying MapLibre GL map. Since 0.7,
mapReady() is the idiomatic way in — it resolves with the live map once the
document has loaded (immediately if it already has) and rejects if the
document fails, so there is no load-listener or null-guard boilerplate:
import * as maplibregl from "maplibre-gl"; // resolved by the import map (or your bundler)
const mapEl = document.querySelector("ml-map");const map = await mapEl.mapReady();
// Now you can use any MapLibre GL APImap.flyTo({ center: [-122.4194, 37.7749], zoom: 14, duration: 2000,});
// Add custom controlsmap.addControl(new maplibregl.NavigationControl());
// Add custom sources and layersmap.addSource("custom-source", { type: "geojson", data: { type: "FeatureCollection", features: [] },});map.addLayer({ id: "custom-layer", type: "fill", source: "custom-source", paint: { "fill-color": "#ff0000" },});The event-based form (ml-map:load listener + getMap()) still works and
suits code that must re-run on every reload.
Controlling Live Data
Section titled “Controlling Live Data”Polling runs on the refreshInterval declared in YAML, and every refresh
fires ml-map:layer-data-loading / ml-map:layer-data-loaded, so you can
follow it from the element:
const mapEl = document.querySelector("ml-map");
mapEl.addEventListener("ml-map:layer-data-loaded", (event) => { const { layerId, featureCount } = event.detail; console.log(`${layerId} refreshed: ${featureCount} features`);});Complete Example: Earthquake Monitor
Section titled “Complete Example: Earthquake Monitor”This example demonstrates a complete implementation using the src attribute with event handlers for status updates.
index.html:
<!DOCTYPE html><html> <head> <title>Earthquake Monitor</title> <link href="https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.css" rel="stylesheet" /> <script type="importmap"> { "imports": { "maplibre-gl": "https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.mjs" } } </script> <script type="module" src="https://unpkg.com/@maplibre-yaml/core/register.js" ></script> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: system-ui, sans-serif; }
.container { display: flex; flex-direction: column; height: 100vh; }
header { padding: 15px 20px; background: #1f2937; color: white; display: flex; justify-content: space-between; align-items: center; }
h1 { font-size: 1.25rem; font-weight: 600; }
.status { font-size: 0.875rem; color: #9ca3af; }
.status.loading { color: #fbbf24; } .status.error { color: #ef4444; } .status.ready { color: #22c55e; }
ml-map { flex: 1; display: block; } </style> </head> <body> <div class="container"> <header> <h1>🌍 Earthquake Monitor</h1> <div class="status" id="status">Initializing...</div> </header>
<ml-map id="earthquake-map" src="earthquake-config.yaml"></ml-map> </div>
<script type="module"> const statusEl = document.getElementById("status"); const mapEl = document.getElementById("earthquake-map");
// Event handlers mapEl.addEventListener("ml-map:loading", (event) => { statusEl.textContent = "Loading configuration..."; statusEl.className = "status loading"; });
// Fires on the first load and again on every refresh. The first one // arrives before ml-map:load, which only fires once every layer is in. mapEl.addEventListener("ml-map:layer-data-loading", () => { statusEl.textContent = "Loading earthquake data..."; statusEl.className = "status loading"; });
mapEl.addEventListener("ml-map:layer-data-loaded", (event) => { const { featureCount } = event.detail; const time = new Date().toLocaleTimeString(); statusEl.textContent = `${featureCount} earthquakes · ${time}`; statusEl.className = "status ready"; });
mapEl.addEventListener("ml-map:layer-data-error", (event) => { statusEl.textContent = `Data error: ${event.detail.error.message}`; statusEl.className = "status error"; });
mapEl.addEventListener("ml-map:error", (event) => { statusEl.textContent = `Error: ${event.detail.error?.message || event.detail.errors?.[0]?.message}`; statusEl.className = "status error"; }); </script> </body></html>earthquake-config.yaml:
type: mapid: earthquake-monitorconfig: center: [-120, 40] zoom: 4 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: earthquakes type: circle source: type: geojson url: "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson" refresh: refreshInterval: 60000 updateStrategy: replace paint: circle-radius: - interpolate - ["linear"] - ["get", "mag"] - 0 - 4 - 5 - 20 circle-color: - interpolate - ["linear"] - ["get", "mag"] - 0 - "#22c55e" - 3 - "#eab308" - 5 - "#ef4444" circle-opacity: 0.8 circle-stroke-width: 1 circle-stroke-color: "#ffffff" interactive: hover: cursor: pointer click: popup: - h3: - property: title - p: - str: "Magnitude: " - property: mag - p: - str: "Depth: " - property: depth - str: " km"Key improvements in this example:
- ✅ No YAML parsing code needed - component handles it
- ✅ Simpler, more readable HTML
- ✅ YAML file is separate and easy to edit
- ✅ Event handlers provide rich status feedback
Multiple Maps on One Page
Section titled “Multiple Maps on One Page”Each <ml-map> is independent:
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px; height: 100vh;"> <ml-map id="map-1" style="display: block;"></ml-map> <ml-map id="map-2" style="display: block;"></ml-map></div>
<script type="importmap"> { "imports": { "maplibre-gl": "https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.mjs" } }</script><script type="module"> import "https://unpkg.com/@maplibre-yaml/core/register.js";
// Configure each map independently document.getElementById("map-1").config = { /* config 1 */ }; document.getElementById("map-2").config = { /* config 2 */ };</script>Framework Compatibility
Section titled “Framework Compatibility”<ml-map> is a standard custom element, so any framework can render it. Import
@maplibre-yaml/core/register once (it defines the element) and import
maplibre-gl/dist/maplibre-gl.css in your entry point. Three things differ
from framework to framework: how config reaches the element, how you listen
for ml-map:* events, and TypeScript.
| Framework | config object | ml-map:* events | TypeScript |
|---|---|---|---|
| React 19 | Set as a property: pass the object or a JSON string | onml-map:load={…} prop, or a ref + addEventListener | @maplibre-yaml/core/react |
| React 18 | Set as an attribute: you must JSON.stringify it (an object becomes "[object Object]") | Ref + addEventListener only | @maplibre-yaml/core/react |
| Vue 3 | Set as a property: pass the object | @ml-map:load="…" | Works (add isCustomElement, below) |
| Svelte 5 | Set as a property: pass the object | bind:this + addEventListener | Works out of the box |
src="/map.yaml" works the same everywhere. The React snippets below are
files from examples/react,
a Vite + TypeScript app that CI builds against React 18 and React 19 and
drives in a browser; a test fails if a snippet here and its file differ. The
Vue and Svelte snippets were verified with Vue 3.5 and Svelte 5 on Vite 8
(dev server and production build), and the Next.js one with Next.js 16.
mapReady() needs 0.7 or later. On 0.6, listen for ml-map:load and then
call getMap().
Since 0.7, a config property set before the element is attached (what React
19, Vue and Svelte do) is validated and given its schema defaults like every
other path: an invalid document shows the “Configuration Error” card. On 0.6
that path skipped validation.
The complete, runnable version of everything below is
examples/react,
with YAML and object configs, events, mapReady(), slot children, the params
panel, mount/unmount under StrictMode, and the React 19 props. See also the
short React guide.
Rendering a map from a config object, for React 18 and 19:
import "@maplibre-yaml/core/register";import type { MapBlock } from "@maplibre-yaml/core";
export function ConfigMap({ config }: { config: MapBlock }) { // A JSON string works on React 18 (set as the attribute) and React 19 // (set as the property). An equal string never re-renders the map. return ( <ml-map config={JSON.stringify(config)} style={{ display: "block", width: "100%", height: "400px" }} /> );}A JSON string works on both majors. React 18 sets it as the attribute before
the element connects. React 19 sets the config property, which parses the
string. On React 19 alone you can pass the object directly
(config={config}), shown further down.
Events and the map instance. React 18 cannot attach listeners for custom
events through props, so use a ref. The same code works on React 19, and the
listeners are typed from MLMapEventMap:
import "@maplibre-yaml/core/register";import type { MLMap, MLMapEventMap } from "@maplibre-yaml/core/register";import { useEffect, useRef, useState } from "react";
export function YamlMap({ src }: { src: string }) { const ref = useRef<MLMap>(null); const [clicked, setClicked] = useState<string | null>(null); const [zoom, setZoom] = useState<number | null>(null);
useEffect(() => { const el = ref.current!; const onClick = (event: MLMapEventMap["ml-map:layer-click"]) => setClicked(String(event.detail.feature?.properties?.name)); const onError = (event: MLMapEventMap["ml-map:error"]) => console.error(event.detail.error ?? event.detail.errors); el.addEventListener("ml-map:layer-click", onClick); el.addEventListener("ml-map:error", onError);
// mapReady() resolves with the MapLibre map once the document loaded. let cancelled = false; el.mapReady().then( (map) => { if (cancelled) return; setZoom(map.getZoom()); map.on("zoomend", () => setZoom(map.getZoom())); }, () => {} // a failed document also fires ml-map:error );
return () => { cancelled = true; el.removeEventListener("ml-map:layer-click", onClick); el.removeEventListener("ml-map:error", onError); }; }, []);
return ( <> <ml-map ref={ref} src={src} style={{ display: "block", height: "400px" }} /> <p> Zoom {zoom?.toFixed(1) ?? "…"} · clicked: {clicked ?? "nothing yet"} </p> </> );}React 19: object config and event props. React 19 sets custom-element
props as properties and attaches on* props as listeners, using the rest of
the name as the event type: onml-map:load={handler} listens for
ml-map:load. With the typings, each handler gets its event’s detail type.
React 18 ignores these props, and the typings reject an object config there:
import "@maplibre-yaml/core/register";import type { MapBlock } from "@maplibre-yaml/core";
// React 19 only: `config` takes the object itself (React 19 sets it as a// property), and `onml-map:*` props attach listeners, typed per event.export function EventPropsMap({ config, onLoad, onLayerClick,}: { config: MapBlock; onLoad: () => void; onLayerClick: (layerId: string) => void;}) { return ( <ml-map config={config} onml-map:load={() => onLoad()} onml-map:layer-click={(event) => onLayerClick(event.detail.layerId)} onml-map:error={(event) => console.error(event.detail.error ?? event.detail.errors) } style={{ display: "block", height: "400px" }} /> );}The colon makes these namespaced JSX attributes. Vite and TypeScript compile
them; if your JSX compiler rejects namespaced attributes, spread them in:
{...{ "onml-map:load": handler }}.
Slot children. React can render your own controls into the map’s corners
(see Slots). The element moves slot children into the map, so render
them unconditionally: React may update what is inside them, but must not add
or remove the slotted elements themselves. Toggle one with hidden or CSS
instead.
import "@maplibre-yaml/core/register";import type { MLMap } from "@maplibre-yaml/core/register";import { useRef } from "react";
export function MapWithControls({ src }: { src: string }) { const ref = useRef<MLMap>(null); const zoomBy = (delta: number) => { const map = ref.current?.getMap(); map?.easeTo({ zoom: map.getZoom() + delta, duration: 300 }); };
// Children with slot="top-left" (and the other corners, or "legend") are // placed in the map's corner, stacked with the built-in chrome. Render // slot children unconditionally: the element moves them into the map, so // React may update their contents but must not add or remove them. return ( <ml-map ref={ref} src={src} style={{ display: "block", height: "400px" }}> <div slot="top-left" className="map-buttons"> <button type="button" aria-label="Zoom in" onClick={() => zoomBy(1)}> + </button> <button type="button" aria-label="Zoom out" onClick={() => zoomBy(-1)}> − </button> </div> </ml-map> );}Lifecycle. Unmounting removes the element, and its disconnectedCallback
destroys the MapLibre map: no leaked canvas, and a remount creates exactly one
new map. StrictMode’s double-run effects do not re-create the element, so
keep listener setup in an effect that has a cleanup, as shown above. In React
18 a ref or effect only runs after the element has connected, so a config
assigned there arrives late; since 0.7 the element waits for it (see the note
under Method 3), but the JSON-string
attribute, present at connect, remains the simplest React 18 path.
TypeScript with React
Section titled “TypeScript with React”React’s JSX types do not know <ml-map>, so without help tsc reports
Property ‘ml-map’ does not exist on type ‘JSX.IntrinsicElements’. Core ships
the typings as a types-only entry. Reference it once, for example in Vite’s
src/vite-env.d.ts:
/// <reference types="@maplibre-yaml/core/react" />(import "@maplibre-yaml/core/react"; in any module works too; the module
is empty at runtime.) It works with @types/react 18 and 19 and adapts to
the one you have installed:
@types/react 19 | @types/react 18 | |
|---|---|---|
src | string | string |
config | MapBlock object or JSON string | JSON string only (an object would arrive as "[object Object]") |
ref | useRef<MLMap>(null): mapReady(), getMap() typed | same |
onml-map:* props | typed per event, for example event.detail.layerId on onml-map:layer-click | not typed (React 18 ignores them) |
children | slot children, inline <script type="text/yaml"> | same |
MLMap and MLMapEventMap are exported from @maplibre-yaml/core/register,
and el.addEventListener("ml-map:layer-click", …) is typed on any MLMap
without React. Core also declares HTMLElementTagNameMap["ml-map"], so
document.querySelector("ml-map") and document.createElement("ml-map") are
typed as MLMap without a cast.
Next.js (App Router)
Section titled “Next.js (App Router)”Client components still render on the server, and the element class extends
HTMLElement, which does not exist there. A top-level
import "@maplibre-yaml/core/register" in a "use client" file therefore
fails the build with ReferenceError: HTMLElement is not defined. Register the
element from an effect instead:
"use client";import { useEffect } from "react";
export default function YamlMap({ src }) { useEffect(() => { import("@maplibre-yaml/core/register"); }, []);
return <ml-map src={src} style={{ display: "block", height: "400px" }} />;}The server sends a plain <ml-map> tag, and it upgrades once the import
resolves. Import the MapLibre CSS in app/layout. On maplibre-gl 6, call
setWorkerUrl() with your bundler’s URL for the worker before that import —
see Installation.
Vue sets config as a DOM property, so pass the object as it is:
<template> <ml-map ref="mapEl" :config="config" style="width: 100%; height: 400px; display: block;" @ml-map:error="(e) => console.error(e.detail.error ?? e.detail.errors)" /></template>
<script setup>import "@maplibre-yaml/core/register";import { onMounted, ref } from "vue";
defineProps(["config"]);const mapEl = ref(null);
onMounted(async () => { const map = await mapEl.value.mapReady(); map.flyTo({ zoom: 3 });});</script>Tell Vue’s compiler that ml-* tags are custom elements. Without this, every
render logs Failed to resolve component: ml-map in development:
import { defineConfig } from "vite";import vue from "@vitejs/plugin-vue";
export default defineConfig({ plugins: [ vue({ template: { compilerOptions: { isCustomElement: (tag) => tag.startsWith("ml-") }, }, }), ],});vue-tsc accepts <ml-map> by default. Under strictTemplates it reports
the element as unknown.
Svelte
Section titled “Svelte”Svelte 5 sets config as a property, so pass the object as it is:
<script> import "@maplibre-yaml/core/register"; import { onMount } from "svelte";
let { config } = $props(); let mapEl;
onMount(() => { const onError = (e) => console.error(e.detail.error ?? e.detail.errors); mapEl.addEventListener("ml-map:error", onError); mapEl.mapReady().then((map) => map.flyTo({ zoom: 3 }), () => {}); return () => mapEl.removeEventListener("ml-map:error", onError); });</script>
<ml-map bind:this={mapEl} {config} style="width: 100%; height: 400px; display: block;"></ml-map>Close the tag explicitly: Svelte 5 warns about self-closing non-void elements
such as <ml-map />. onml-map:load={…} attaches a listener, but Svelte warns
that attribute names should not contain :, so use bind:this as shown.
svelte-check accepts <ml-map> and its attributes out of the box. With
lang="ts", type the binding as let mapEl: MLMap (imported from
@maplibre-yaml/core/register).
Troubleshooting
Section titled “Troubleshooting”Component Not Rendering
Section titled “Component Not Rendering”Symptoms: <ml-map> element exists but shows nothing.
Solutions:
-
Check registration
// Verify component is registeredconsole.log(customElements.get("ml-map")); // Should not be undefined -
Check dimensions
ml-map {display: block; /* Required! */width: 100%;height: 400px;} -
Check config is valid JSON
try {JSON.parse(mapEl.getAttribute("config"));} catch (e) {console.error("Invalid JSON config");}
”Configuration Error” Card
Section titled “”Configuration Error” Card”Symptoms: The element shows a red “Configuration Error” card listing
field paths and messages (for example id: Expected string, got undefined).
Cause: The configuration failed to fetch, parse, or validate against the schema.
Debug:
mapEl.addEventListener("ml-map:error", (event) => { // Each entry is { path, message } — the path names the invalid field console.error("Config errors:", event.detail.errors);});Common fixes:
- Ensure
type: "map"is present - Ensure
idis a string - Ensure
config.centeris[longitude, latitude] - Ensure
config.mapStyleis a valid URL
Events Not Firing
Section titled “Events Not Firing”Symptoms: Event listeners never trigger.
Solutions:
-
Add listeners before setting config
const mapEl = document.getElementById('my-map');// ✅ Add listener firstmapEl.addEventListener('ml-map:load', () => {});// Then set configmapEl.config = { ... }; -
Check for errors
mapEl.addEventListener("ml-map:error", (event) => {console.error("Error prevented load:", event.detail.error);});
Map Loads but No Layers Visible
Section titled “Map Loads but No Layers Visible”Symptoms: Base map shows, but data layers don’t appear.
Debug steps:
-
Check layer loading events
mapEl.addEventListener("ml-map:layer-data-loading", (e) =>console.log("Loading:", e.detail.layerId));mapEl.addEventListener("ml-map:layer-data-loaded", (e) =>console.log("Loaded:", e.detail.layerId, e.detail.featureCount));mapEl.addEventListener("ml-map:layer-data-error", (e) =>console.error("Error:", e.detail.layerId, e.detail.error)); -
Check network requests - Open browser DevTools → Network tab
-
Verify GeoJSON URL - Open the URL directly in browser
-
Check for CORS issues - Look for CORS errors in console
Component API Reference
Section titled “Component API Reference”Attributes
Section titled “Attributes”| Attribute | Type | Description |
|---|---|---|
src | string | URL to external YAML configuration file |
config | JSON string | Map configuration as stringified JSON |
Properties
Section titled “Properties”| Property | Type | Description |
|---|---|---|
config | Object or string | Map configuration object (set/get) |
Methods
Section titled “Methods”| Method | Returns | Description |
|---|---|---|
mapReady() | Promise<maplibregl.Map> | Resolves with the live map once the document loads (immediately if loaded); rejects on ml-map:error |
getMap() | maplibregl.Map | null | The MapLibre GL map instance (null before a render) |
getRenderer() | MapRenderer | null | The underlying renderer |
isLoaded() | boolean | Whether the map finished loading |
reload() | Promise<void> | Re-render from src or the inline YAML script; slot children are kept |
addLayer(layer) | Promise<void> | Add a layer (same shape as a document layer) |
removeLayer(layerId) | void | Remove a layer |
setLayerVisibility(id, on) | void | Show or hide a layer |
updateLayerData(id, geojson) | void | Replace a GeoJSON layer’s data |
Refresh control (pauseRefresh / resumeRefresh / refreshNow) is not
exposed yet — see bead ml-few.
Events
Section titled “Events”See the event reference above for the complete list — seventeen events covering lifecycle, layer data, interactions, markers, images, and the params panel.
Trust and attribution
Section titled “Trust and attribution”Attribution is the one place a document’s text reaches an HTML sink that
maplibre-yaml does not own: MapLibre renders the attribution control with
innerHTML, behind its own sanitizer. That sanitizer can be bypassed in
every maplibre-gl release up to 6.4.0
(GHSA-jrc7-96c5-q579).
It is patched upstream in maplibre-gl 6.4.1, which is why the v6 part of
the peer range starts there (^6.4.1) — prefer v6 for new projects. The v4 and
v5 lines get no fix, and a host may run any version, so MapLibre’s sanitizer is
still not treated as a safe boundary for a document someone else wrote.
maplibre-yaml therefore sanitizes attribution itself before MapLibre sees it.
What survives is plain text, character references (©), and <a> links
whose href uses http:, https: or mailto:, keeping only the href,
target and rel attributes. Any other markup is escaped and shown as
literal text rather than dropped, so the credit stays readable. This covers
every route attribution takes into the control:
- a source’s
attribution:(named, inline, and v1/v2 alike), customAttributionundercontrols.attributionor aattributionControl: { … }options object inconfig/runtime.map,- attribution carried by resources the document points at: a basemap style
URL (
mapStyle/basemap), an inline style object, and a TileJSON behind a sourceurl:.
This applies under every capability policy, trusted included. !html
popups render raw markup for a trusted document because maplibre-yaml’s own
popup sink is sound; the attribution sink is MapLibre’s, and trusting the
author does not make it safe. The only cost to a trusted document is that
non-link markup such as <b> in attribution shows as text.
mlym emit applies the same rule to the style.json it writes, because that
file may be rendered by an older maplibre-gl, and reports a contract
warning naming each source whose attribution was changed. Releases before
this fix (0.6.0 and earlier) forward attribution unsanitized: upgrade
(0.6.1, 0.7.0, or later), or do not render documents you did not author
with them.
Upgrading to 0.6
Section titled “Upgrading to 0.6”Three behavior changes are worth knowing when you move to 0.6:
!htmlis trust-gated, not unconditional. A popup!htmlvalue renders as raw markup only under a trusted capability policy; otherwise it is escaped and shown as text. Under<ml-map>(untrusted by default)!htmlcontent is escaped.- Malformed inline GeoJSON now warns. A v1 document with malformed inline
GeoJSON
dataemits a warning (promoted to an error undermlym validate --strict/ CI). Pre-check inline geometry. - v2
style.metadataflows through. In Format v2,style.metadatais carried to the emittedstyle.jsonrootmetadataslot.
Next Steps
Section titled “Next Steps”- Format v2 - The
style:/runtime:split (renders identically here) - Interactions & Export to style.json - Declarative interactions and the
emitgate - Vanilla JavaScript Integration - More control with direct API usage
- Astro Integration - For Astro-based sites
- Live Data Guide - Polling and streaming configuration