Interactions & Eject
A maplibre-yaml document carries two kinds of thing: the parts that become a
MapLibre style.json, and the parts that only exist while the map is running.
Interactions — popups, camera moves, hover highlighting, host events — are the
second kind. They are declared in your YAML but they are behavior, and no
MapLibre style can hold behavior.
This guide is about what that means in practice: how the declarative
interactions are authored (a recap), why a compiled style is inert on its own
(the eject guarantee), and how attachInteractions wires the behavior back
onto any map so your interactions survive eject.
Declarative interactions, in one recap
Section titled “Declarative interactions, in one recap”Every interaction lives under a layer’s interactive: block, split into a
hover: trigger and a click: trigger. The named interactions core ships are:
click.popup— open a popup built from the clicked feature.click.flyTo— animate the camera to author-fixed coordinates (defaults to the clicked point).click.zoomToFeature— fit the camera to the clicked feature’s own bounds.hover.highlight— recolour the hovered feature viafeature-state.
version: 1type: mapid: stations-mapconfig: center: [-73.98, 40.75] zoom: 12 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: stations type: circle source: type: geojson url: "https://example.com/stations.geojson" generateId: true paint: circle-radius: 8 circle-color: "#3b82f6" interactive: hover: cursor: pointer highlight: true click: popup: - h3: [{ property: name }] - p: [{ property: description }] zoomToFeature: padding: 40 maxZoom: 16 duration: 300When a layer configures both a popup and a camera move, the popup opens first
and then travels with the camera. highlight needs feature ids — set
generateId: true on the source, or promoteId to use a property.
This is only a recap. Every field, its defaults, and the emit interaction are
documented in full in the Interactivity schema
reference.
The eject guarantee
Section titled “The eject guarantee”projectStyle(model) compiles your document into a self-contained, spec-valid
style.json. That artifact renders anywhere MapLibre runs — vanilla
maplibre-gl, maplibre-native, a third-party tool that eats style JSON — with
nothing from this library present at runtime. That is the eject guarantee: your
map is not locked to <ml-map>.
The other half of the guarantee is the honest half: the compiled style is inert. Nothing in the MapLibre style specification can express “open a popup on click” or “fly the camera to this feature.” Those verbs do not exist in the spec. So the compiled style is a picture of your map, not its behavior — it draws the layers, and clicking a feature does nothing.
import maplibregl from "maplibre-gl";import { YAMLParser, normalizeMapBlock, projectStyle, mergeBasemap,} from "@maplibre-yaml/core";
const parsed = YAMLParser.safeParseMapBlock(doc);const model = normalizeMapBlock(parsed.data);
// Compile the style half. The interactive half is NOT in here.const projected = projectStyle(model, "with-fallbacks");const base = model.style.basemap;const { style } = base ? mergeBasemap(base, projected) : projected;
// A plain MapLibre map over the compiled style. Nothing from this library// participates in rendering — and nothing makes the layers interactive.new maplibregl.Map({ container: "map", style });Reattachment: attachInteractions
Section titled “Reattachment: attachInteractions”If the compiled style is inert, how do interactions survive eject? You wire them
back on. attachInteractions(map, projection, options) takes the declarative
projection your document produced and binds the built-in handlers onto any
maplibregl.Map — a map rendering a compiled/ejected style, or a map a host
built entirely itself with its own sources and layers. No <ml-map> is
involved.
The behavior travels as data, separate from the style. projectInteractions
extracts it; attachInteractions binds it:
import { projectInteractions, attachInteractions, createInteractionRegistry,} from "@maplibre-yaml/core";
// The declarative interactions projection — pure data, no behavior yet.const projection = projectInteractions(model, { trust: "trusted" });
// Wire it onto ANY maplibregl.Map: a compiled-style map, or the host's own.const handle = attachInteractions(map, projection, { registry: createInteractionRegistry(), policy: { trust: "trusted" }, hostHandlers: { "select-feature": (payload) => { /* host code runs here */ }, },});
// The returned InteractionsHandle drives the lifecycle:handle.resetFeatureState("stations"); // drop hover state after a data refreshhandle.detach("stations"); // release one layer's listenershandle.destroy(); // release everything, remove any popupThe public API surface from packages/core/src/interactions/index.ts is small:
attachInteractions(map, projection, options)— bind the projection onto a map; returns anInteractionsHandle.projectInteractions(model, policy?)— extract the declarative projection from a model.createInteractionRegistry()— a fresh registry resolving interaction names to built-ins (each attach gets its own).InteractionsHandle— the returned lifecycle handle:{ resetFeatureState, detach, destroy }.
emit: a host event, and what it truthfully requires
Section titled “emit: a host event, and what it truthfully requires”click.emit is the interaction that hands data to code the host wrote. Unlike
popup, flyTo, and zoomToFeature — which act on the map itself —
emit dispatches a named event to a host-supplied handler. Because it hands
control to host code, it is gated, and the gate is the point.
version: 1type: mapid: parcels-mapconfig: center: [-73.98, 40.75] zoom: 12 mapStyle: "https://demotiles.maplibre.org/style.json"layers: - id: parcels type: fill source: type: geojson url: "https://example.com/parcels.geojson" generateId: true paint: fill-color: "#2563eb" fill-opacity: 0.6 interactive: click: emit: event: select-feature payload: id: { property: pid } kind: { str: parcel }emit fires only through attachInteractions, and only when both of these
hold at once:
- A trusted policy.
options.policymust declaretrust: "trusted". The host hook is default-deny; an untrusted — or absent — policy shuts the gate and the interaction does nothing. There is no per-policy override for this: a host hook runs host code, and the only situation where that is safe is one where the document author is the host. - A matching handler.
options.hostHandlersmust carry an entry whose key equals the event name. Resolution is closed-world — an unregistered name (or a prototype method liketoString) is denied, never dispatched.
Both are required. A trusted policy with no registered handler does nothing; a registered handler under an untrusted policy does nothing. It needs BOTH.
The payload projection — str, property, else, and the defined-absent
rule — is specified in the emit section of the Interactivity
reference.
Next steps
Section titled “Next steps”- Interactivity reference — every interaction field,
the full
emitpayload vocabulary, and the deprecatedactionkey - Format v2 — the style/runtime split that makes the eject boundary legible
- Working with Layers — the paint, layout, and filter that
compile straight through to
style.json