Skip to content

Interactivity

Interactivity in maplibre-yaml is primarily configured through:

  • Map Controls - Navigation, scale, geolocation, fullscreen
  • Legends - Interactive layer information
  • Layer Interactions - Click, hover, and pointer events (configured at layer level)

Map controls provide standard UI elements for map interaction. See Map Configuration for detailed control configuration.

controls:
navigation: true # Zoom and rotation controls
geolocate: true # User geolocation button
scale: true # Distance scale
fullscreen: true # Fullscreen toggle
attribution: true # Attribution text

Each control can be positioned independently:

controls:
navigation:
enabled: true
position: top-left
scale:
enabled: true
position: bottom-right

Available positions:

  • top-left
  • top-right
  • bottom-left
  • bottom-right

attribution renders a real attribution control and additionally accepts MapLibre’s own options:

controls:
attribution:
position: bottom-right # default
compact: true # collapse behind an (i) button
customAttribution: "© Example Data"

Configuring it suppresses MapLibre’s built-in attribution so only one control renders. Leave it unset and the built-in one still appears — attribution is a licensing obligation for most tile providers, so it is never removed silently.

Legends display layer information and can be positioned anywhere on the map.

legend:
title: "Map Features"
position: top-right
collapsed: false
legend:
title: "Data Categories"
position: top-left
items:
- color: "#e74c3c"
label: "High Priority"
shape: circle
- color: "#f39c12"
label: "Medium Priority"
shape: circle
- color: "#2ecc71"
label: "Low Priority"
shape: circle
  • circle - Circular symbol
  • square - Square symbol
  • line - Line symbol
  • icon - Custom icon (requires icon property)
legend:
title: "Transportation"
items:
- color: "#3b82f6"
label: "Highway"
shape: line
- color: "#10b981"
label: "Local Road"
shape: line
- color: "#000000"
label: "Restaurant"
shape: icon
icon: "restaurant"

Control which user interactions are enabled.

Create a static presentation map:

config:
center: [-74.006, 40.7128]
zoom: 12
interactive: false
mapStyle: "..."

Enable specific interactions:

config:
center: [-74.006, 40.7128]
zoom: 12
scrollZoom: true
dragPan: true
dragRotate: false # Disable rotation
touchPitch: false # Disable pitch on mobile
doubleClickZoom: false # Disable double-click zoom
mapStyle: "..."
PropertyTypeDefaultDescription
interactivebooleantrueEnable all interactions
scrollZoombooleantrueScroll to zoom
boxZoombooleantrueShift+drag box zoom
dragRotatebooleantrueRight-drag to rotate
dragPanbooleantrueDrag to pan
keyboardbooleantrueKeyboard shortcuts
doubleClickZoombooleantrueDouble-click zoom
touchZoomRotatebooleantrueTouch zoom/rotate
touchPitchbooleantrueTwo-finger pitch

Layers declare interactivity through an interactive: block. These are real schema fields handled by the renderer — not something you wire up yourself.

- 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 }]
flyTo:
zoom: 14
duration: 600
PropertyTypeDescription
cursorstringCSS cursor while the pointer is over a feature
highlightbooleanRecolour the hovered feature

highlight: true wraps the layer’s primary colour in a feature-state expression for you, so no paint authoring is required. Two things follow from how feature-state works:

  • Features need ids. Set generateId: true on the source, or promoteId to use a property as the id. If neither is set, highlight enables generateId and warns — but generated ids are not stable across data refreshes, so promoteId is the durable choice.
  • Already using an expression for that colour? Then highlight leaves your paint alone and warns, rather than overwriting your data-driven styling. Reference ["feature-state", "hover"] yourself, as in Custom highlight styling below.

Because feature-state is per source, two layers sharing one source share highlight state.

PropertyTypeDescription
popuparrayPopup content blocks (see Popups)
flyToobjectAnimate the camera to the clicked feature
emitobjectDispatch a named host event with a payload projected from the feature

flyTo accepts center, zoom, and duration. center defaults to the clicked point, and zoom/duration fall through to MapLibre’s defaults when omitted:

interactive:
click:
flyTo:
zoom: 14 # optional
duration: 600 # optional, milliseconds

When a layer configures both, the popup opens first and then travels with the camera.

click.emit dispatches a named host event carrying a declarative payload projected from the clicked feature. Unlike popup and flyTo, which act on the map itself, emit hands data to code the host wrote — so it is gated on trust (see the caveat below).

PropertyTypeDescription
eventstringThe event name, resolved against the host’s handler map
payloadobjectNamed keys, each a declarative projection from the feature

Each payload key is projected independently:

  • str — a static literal value.
  • property — read a feature property, with an optional else fallback.
version: 1
type: map
id: parcels-map
config:
center: [-73.98, 40.75]
zoom: 12
mapStyle: "https://demotiles.maplibre.org/style.json"
layers:
- id: parcels
type: circle
source:
type: geojson
url: "https://example.com/parcels.geojson"
generateId: true
paint:
circle-radius: 6
circle-color: "#3b82f6"
interactive:
click:
emit:
event: select
payload:
id:
property: id
label:
property: name
else: "Untitled"
kind:
str: parcel

A missing or null property yields a defined-absent value: the key stays in the payload carrying its else value, or null when no else is declared. It is never omitted and never throws, so a handler sees a stable payload shape across features that carry different properties. Payload fields are data only — str, property, and else — with no formatting, markup, or executable fields; unlike popup, an unrecognised field is stripped as an unknown key rather than passed through.

emit needs a trusted host — it is inert under <ml-map> today. It is a host-event interaction: it fires only when the map is wired up with attachInteractions(map, projection, options) and both of these hold:

  • options.policy declares trust: "trusted". The host hook is default-deny; an untrusted (or absent) policy shuts the gate and the interaction does nothing.
  • options.hostHandlers has a matching entry for the event name. Resolution is closed-world — an unregistered name is denied, never dispatched.

Both are required: a trusted policy with no handler does nothing, and a registered handler under an untrusted policy does nothing.

The projection you pass in must carry emit in the first place: it fires only when projectInteractions(model, policy) also ran under a trusted policy. An untrusted (or omitted) projection policy drops the emit block at projection time — fail-closed — so attachInteractions never sees it, no matter how it is configured. The Interactions & Eject guide shows the end-to-end wiring (projectInteractions(model, { trust: "trusted" })attachInteractions).

Because of this, emit is a silent no-op under the <ml-map> web component, which defaults to an untrusted policy and registers no host handlers (fail-closed). Making emit live under <ml-map> is tracked as follow-up work (bead ml-1lz).

click.action, mouseenter.action, and mouseleave.action are accepted by the schema but have never been dispatched at runtime. They emit a deprecation warning and will be removed in v2. Listen for the corresponding event instead:

# Deprecated — never fired
interactive:
click:
action: openDetails
// Use the event instead
map.addEventListener("ml-map:layer-click", (e) => {
const { layerId, feature } = e.detail;
});

Deprecation warnings are exempt from mlym validate’s CI strict mode for one minor release, so upgrading will not fail your build. Opt in early with mlym validate --strict-deprecations.

To control the highlight colour yourself, author the paint expression directly and leave highlight off:

- id: interactive-fill
type: fill
source:
type: geojson
url: "https://example.com/regions.geojson"
generateId: true
paint:
fill-color:
- case
- ["boolean", ["feature-state", "hover"], false]
- "#3b82f6" # Blue when hovered
- "#e5e7eb" # Gray otherwise
fill-opacity:
- case
- ["boolean", ["feature-state", "hover"], false]
- 0.8
- 0.5
interactive:
hover:
cursor: pointer

When keyboard interaction is enabled (keyboard: true), the following shortcuts are available:

KeysAction
+ / =Zoom in
-Zoom out
Arrow keysPan map
Shift + Arrow keysRotate map
Shift + + / -Increase/decrease pitch
config:
center: [0, 0]
zoom: 2
keyboard: false
mapStyle: "..."

Control touch gestures on mobile devices.

config:
center: [0, 0]
zoom: 2
touchZoomRotate: true
touchPitch: true
mapStyle: "..."

Prevent accidental camera tilt:

config:
center: [0, 0]
zoom: 2
touchZoomRotate: true
touchPitch: false
mapStyle: "..."

Sync map state (center, zoom, bearing, pitch) with the URL hash for bookmarkable views.

config:
center: [-74.006, 40.7128]
zoom: 12
hash: true # Enable URL hash syncing
mapStyle: "..."

When enabled, the URL will update as users interact with the map:

https://example.com/map#12/40.7128/-74.006

The map automatically changes the cursor to indicate interactivity:

  • Default cursor over the map
  • Pointer cursor over interactive features
  • Grab cursor when dragging
- type: map
id: interactive-map
config:
center: [-74.006, 40.7128]
zoom: 12
hash: true
mapStyle: "https://demotiles.maplibre.org/style.json"
layers:
- id: buildings
type: fill
source:
type: geojson
url: "https://example.com/buildings.geojson"
paint:
fill-color:
- case
- ["boolean", ["feature-state", "hover"], false]
- "#3b82f6"
- "#cbd5e1"
fill-opacity: 0.7
controls:
navigation: true
geolocate: true
scale: true
fullscreen: true
legend:
title: "Buildings"
position: top-right
items:
- color: "#cbd5e1"
label: "Building"
shape: square
- color: "#3b82f6"
label: "Hovered"
shape: square
- type: map
id: limited-map
config:
center: [0, 20]
zoom: 2
scrollZoom: true
dragPan: true
dragRotate: false
touchPitch: false
doubleClickZoom: false
keyboard: false
minZoom: 1
maxZoom: 8
mapStyle: "https://demotiles.maplibre.org/style.json"
layers:
- id: data-layer
type: circle
source:
type: geojson
url: "https://example.com/data.geojson"
paint:
circle-radius: 6
circle-color: "#3b82f6"
controls:
navigation: true
scale: true
legend:
title: "Data Points"
position: top-left
- type: map
id: static-map
style: "height: 500px;"
config:
center: [-74.006, 40.7128]
zoom: 12
pitch: 45
bearing: -30
interactive: false
attributionControl: false
mapStyle: "https://demotiles.maplibre.org/style.json"
layers:
- id: buildings
type: fill-extrusion
source:
type: vector
url: "https://demotiles.maplibre.org/tiles/tiles.json"
source-layer: building
paint:
fill-extrusion-color: "#aaa"
fill-extrusion-height: ["get", "height"]
fill-extrusion-opacity: 0.8
legend:
title: "3D Buildings"
position: bottom-right
items:
- color: "#aaa"
label: "Building"
shape: square
- type: map
id: mobile-map
config:
center: [-74.006, 40.7128]
zoom: 12
touchZoomRotate: true
touchPitch: false # Disable pitch to prevent accidental tilts
dragRotate: false # Disable rotation
keyboard: false # Not needed on mobile
mapStyle: "https://demotiles.maplibre.org/style.json"
layers:
- id: points
type: circle
source:
type: geojson
url: "https://example.com/points.geojson"
paint:
circle-radius: 12 # Larger for touch targets
circle-color: "#3b82f6"
circle-stroke-width: 2
circle-stroke-color: "#ffffff"
controls:
navigation:
enabled: true
position: top-right
geolocate:
enabled: true
position: top-right
scale:
enabled: true
position: bottom-left
legend:
title: "Locations"
position: top-left
collapsed: true # Start collapsed on mobile
  1. Limit interactive features: Too many interactive features can impact performance
  2. Use feature-state: Prefer feature-state for hover/select styling over re-rendering
  3. Throttle updates: Limit update frequency for real-time data sources
  1. Provide visual feedback: Use hover states to indicate clickable features
  2. Clear controls: Position controls logically and consistently
  3. Mobile-first: Disable complex gestures like pitch on mobile
  4. Accessibility: Include keyboard navigation and clear visual indicators
  1. Set zoom limits: Use minZoom/maxZoom to keep users in relevant area
  2. Geographic bounds: Use maxBounds to restrict panning
  3. Disable unnecessary interactions: Remove interactions that don’t serve your use case
import {
type ControlsConfig,
type LegendConfig,
type MapConfig
} from '@maplibre-yaml/core/schemas';
const controls: ControlsConfig = {
navigation: {
enabled: true,
position: "top-right"
},
scale: true,
geolocate: true
};
const legend: LegendConfig = {
title: "Features",
position: "top-left",
collapsed: false,
items: [
{ color: "#ff0000", label: "High", shape: "circle" },
{ color: "#00ff00", label: "Low", shape: "circle" }
]
};
const interactiveConfig: MapConfig = {
center: [-74.006, 40.7128],
zoom: 12,
mapStyle: "https://demotiles.maplibre.org/style.json",
interactive: true,
scrollZoom: true,
dragPan: true,
hash: true
};