Skip to content

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.

Get a map on your page in 3 steps.

  1. Add the scripts and styles

    <head>
    <!-- MapLibre GL CSS -->
    <link
    href="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 -->
    <script
    type="module"
    src="https://unpkg.com/@maplibre-yaml/core/register.js"
    ></script>
    </head>
  2. Create a YAML configuration file

    Create map.yaml in your project:

    type: map
    id: quick-map
    config:
    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"
  3. Add the map component

    <ml-map src="map.yaml" style="width: 100%; height: 400px; display: block;">
    </ml-map>
  4. View in browser

    Serve your files with any web server (e.g., npx serve .) and open in browser!

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>
Terminal window
npm install @maplibre-yaml/core maplibre-gl@^6
// In your JavaScript entry point
import "@maplibre-yaml/core/register";
import "maplibre-gl/dist/maplibre-gl.css";

There are three ways to configure <ml-map>, each suited for different use cases.

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: map
id: my-map
config:
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

For self-contained examples or documentation:

<ml-map style="height: 400px; display: block;">
<script type="text/yaml">
type: map
id: inline-map
config:
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 — use src or config there (see Framework Compatibility)
  • Good for examples that need to be self-contained

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
1. Element added to DOM
2. connectedCallback() fires
3. Config attribute/property read
4. Config validated against schema
5. MapLibre GL map created
6. Layers added
7. 'ml-map:load' event dispatched
const mapEl = document.querySelector("ml-map");
// Map loaded and ready
mapEl.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 loaded
mapEl.addEventListener("ml-map:layer-data-loaded", (event) => {
const { layerId, featureCount } = event.detail;
console.log(`${layerId}: ${featureCount} features`);
});
// Layer data loading
mapEl.addEventListener("ml-map:layer-data-loading", (event) => {
console.log(`Loading ${event.detail.layerId}...`);
});
// Layer data error
mapEl.addEventListener("ml-map:layer-data-error", (event) => {
console.error(`${event.detail.layerId} error:`, event.detail.error);
});

Every event <ml-map> dispatches (all bubble; payload on event.detail):

EventDetailFires 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)

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>
SlotPlacement
top-left, top-right, bottom-left, bottom-rightMounted into that corner of the map
legendReplaces 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 new config/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.

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 API
map.flyTo({
center: [-122.4194, 37.7749],
zoom: 14,
duration: 2000,
});
// Add custom controls
map.addControl(new maplibregl.NavigationControl());
// Add custom sources and layers
map.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.

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`);
});

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: map
id: earthquake-monitor
config:
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

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>

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

Frameworkconfig objectml-map:* eventsTypeScript
React 19Set as a property: pass the object or a JSON stringonml-map:load={…} prop, or a ref + addEventListener@maplibre-yaml/core/react
React 18Set as an attribute: you must JSON.stringify it (an object becomes "[object Object]")Ref + addEventListener only@maplibre-yaml/core/react
Vue 3Set as a property: pass the object@ml-map:load="…"Works (add isCustomElement, below)
Svelte 5Set as a property: pass the objectbind:this + addEventListenerWorks 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.

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
srcstringstring
configMapBlock object or JSON stringJSON string only (an object would arrive as "[object Object]")
refuseRef<MLMap>(null): mapReady(), getMap() typedsame
onml-map:* propstyped per event, for example event.detail.layerId on onml-map:layer-clicknot typed (React 18 ignores them)
childrenslot 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.

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:

vite.config.js
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 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).

Symptoms: <ml-map> element exists but shows nothing.

Solutions:

  1. Check registration

    // Verify component is registered
    console.log(customElements.get("ml-map")); // Should not be undefined
  2. Check dimensions

    ml-map {
    display: block; /* Required! */
    width: 100%;
    height: 400px;
    }
  3. Check config is valid JSON

    try {
    JSON.parse(mapEl.getAttribute("config"));
    } catch (e) {
    console.error("Invalid JSON config");
    }

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 id is a string
  • Ensure config.center is [longitude, latitude]
  • Ensure config.mapStyle is a valid URL

Symptoms: Event listeners never trigger.

Solutions:

  1. Add listeners before setting config

    const mapEl = document.getElementById('my-map');
    // ✅ Add listener first
    mapEl.addEventListener('ml-map:load', () => {});
    // Then set config
    mapEl.config = { ... };
  2. Check for errors

    mapEl.addEventListener("ml-map:error", (event) => {
    console.error("Error prevented load:", event.detail.error);
    });

Symptoms: Base map shows, but data layers don’t appear.

Debug steps:

  1. 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)
    );
  2. Check network requests - Open browser DevTools → Network tab

  3. Verify GeoJSON URL - Open the URL directly in browser

  4. Check for CORS issues - Look for CORS errors in console

AttributeTypeDescription
srcstringURL to external YAML configuration file
configJSON stringMap configuration as stringified JSON
PropertyTypeDescription
configObject or stringMap configuration object (set/get)
MethodReturnsDescription
mapReady()Promise<maplibregl.Map>Resolves with the live map once the document loads (immediately if loaded); rejects on ml-map:error
getMap()maplibregl.Map | nullThe MapLibre GL map instance (null before a render)
getRenderer()MapRenderer | nullThe underlying renderer
isLoaded()booleanWhether 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)voidRemove a layer
setLayerVisibility(id, on)voidShow or hide a layer
updateLayerData(id, geojson)voidReplace a GeoJSON layer’s data

Refresh control (pauseRefresh / resumeRefresh / refreshNow) is not exposed yet — see bead ml-few.

See the event reference above for the complete list — seventeen events covering lifecycle, layer data, interactions, markers, images, and the params panel.

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 (&copy;), 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),
  • customAttribution under controls.attribution or a attributionControl: { … } options object in config / 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 source url:.

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.

Three behavior changes are worth knowing when you move to 0.6:

  • !html is trust-gated, not unconditional. A popup !html value renders as raw markup only under a trusted capability policy; otherwise it is escaped and shown as text. Under <ml-map> (untrusted by default) !html content is escaped.
  • Malformed inline GeoJSON now warns. A v1 document with malformed inline GeoJSON data emits a warning (promoted to an error under mlym validate --strict / CI). Pre-check inline geometry.
  • v2 style.metadata flows through. In Format v2, style.metadata is carried to the emitted style.json root metadata slot.