Skip to content

MapLibre — the cross-framework interactive map

MapLibre is Rozie's data-bound port of MapLibre GL JS — the open-source (BSD-3) WebGL map engine, the community fork of Mapbox GL JS v1. One .rozie source file ships idiomatic React, Vue, Svelte, Angular, Solid, and Lit consumers from a single wrapper. The per-framework ecosystem is uneven: react-map-gl / @vis.gl/react-maplibre is deep, @indoorequal/vue-maplibre-gl, svelte-maplibre-gl and @maplibre/ngx-maplibre-gl are solid — but Solid has only a stale/Mapbox-first option and Lit has no real wrapper at all. Rozie collapses all six into one source, and Solid + Lit get a category-leading wrapper for free. See the MapLibre libraries comparison for the full per-framework matrix.

This page is the show-and-tell: the API surface, per-framework quick starts, the 20 map events, the four two-way camera bindings, the imperative handle, the consumer-extensible :sources / :layers / :options passthroughs, and the per-target recipe for the reactive marker / popup portal slots and the mount-once control slot.

The full source for MapLibre.rozie lives in the @rozie-ui/maplibre package.

The @rozie-ui/maplibre packages

MapLibre ships as six pre-compiled, per-framework packages generated from a single MapLibre.rozie source via the package's codegen.mjs doc-automation engine. Consumers install only the one for their framework — no Rozie toolchain, no build-time compile step:

PackageInstallREADME
@rozie-ui/maplibre-reactnpm i @rozie-ui/maplibre-reactreact/README
@rozie-ui/maplibre-vuenpm i @rozie-ui/maplibre-vuevue/README
@rozie-ui/maplibre-sveltenpm i @rozie-ui/maplibre-sveltesvelte/README
@rozie-ui/maplibre-angularnpm i @rozie-ui/maplibre-angularangular/README
@rozie-ui/maplibre-solidnpm i @rozie-ui/maplibre-solidsolid/README
@rozie-ui/maplibre-litnpm i @rozie-ui/maplibre-litlit/README

Each package carries the maplibre-gl engine peer (^5) plus its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core). Install the engine peer alongside the framework package:

bash
npm i @rozie-ui/maplibre-react maplibre-gl

You must import the engine CSS once at your app entry. The component's scoped <style> cannot reach the engine-rendered control / popup / marker DOM (that DOM never carries Rozie's [data-rozie-s-*] scope attribute), so the base MapLibre styles come from the engine's own stylesheet:

ts
import 'maplibre-gl/dist/maplibre-gl.css';

…or <link> the CDN copy in your index.html. Anything the curated prop surface doesn't special-case (custom data sources, styled layers, the full MapOptions bag) comes through the first-class :sources / :layers / :options passthroughs — MapLibre's own config shapes. The per-leaf READMEs and the Props table below are generated from the same IR parse of MapLibre.rozie, so they cannot drift from the compiled output — the package's codegen.mjs asserts the structural columns of this page against ir.props on every run.

Quick start

The camera is two-way bound across four model props — center, zoom, bearing, and pitch. Panning or zooming the map writes the new camera back through those model paths (echo-guarded so a programmatic move doesn't ping-pong); a consumer write reflects into the live map. center is [lng, lat] — longitude FIRST (MapLibre's convention, not Leaflet's [lat, lng]). The map style comes through :map-style (the prop is mapStyle, not style — a reserved attribute across the targets).

React

tsx
import { useState } from 'react';
import { MapLibre } from '@rozie-ui/maplibre-react';
import 'maplibre-gl/dist/maplibre-gl.css';

export function Demo() {
  const [center, setCenter] = useState<[number, number]>([-74.5, 40]);
  const [zoom, setZoom] = useState(9);
  return (
    <div style={{ height: 400 }}>
      <MapLibre
        center={center}
        onCenterChange={setCenter}
        zoom={zoom}
        onZoomChange={setZoom}
        controls={['navigation', 'scale']}
        onClick={(e) => console.log(e.lngLat)}
      />
    </div>
  );
}

Vue

vue
<script setup lang="ts">
import { ref } from 'vue';
import MapLibre from '@rozie-ui/maplibre-vue';
import 'maplibre-gl/dist/maplibre-gl.css';

const center = ref<[number, number]>([-74.5, 40]);
const zoom = ref(9);
</script>

<template>
  <div style="height: 400px">
    <MapLibre
      v-model:center="center"
      v-model:zoom="zoom"
      :controls="['navigation', 'scale']"
      @click="(e) => console.log(e.lngLat)"
    />
  </div>
</template>

Svelte

svelte
<script lang="ts">
  import MapLibre from '@rozie-ui/maplibre-svelte';
  import 'maplibre-gl/dist/maplibre-gl.css';

  let center = $state<[number, number]>([-74.5, 40]);
  let zoom = $state(9);
</script>

<div style="height: 400px">
  <MapLibre
    bind:center
    bind:zoom
    controls={['navigation', 'scale']}
    onclick={(e) => console.log(e.lngLat)}
  />
</div>

Angular

ts
import { Component } from '@angular/core';
import { MapLibre } from '@rozie-ui/maplibre-angular';
// Add 'maplibre-gl/dist/maplibre-gl.css' to your global styles.

@Component({
  selector: 'app-demo',
  standalone: true,
  imports: [MapLibre],
  template: `
    <div style="height: 400px">
      <MapLibre
        [(center)]="center"
        [(zoom)]="zoom"
        [controls]="['navigation', 'scale']"
        (click)="onClick($event)"
      />
    </div>
  `,
})
export class DemoComponent {
  center: [number, number] = [-74.5, 40];
  zoom = 9;
  onClick(e: any) { console.log(e.lngLat); }
}

Solid

tsx
import { createSignal } from 'solid-js';
import { MapLibre } from '@rozie-ui/maplibre-solid';
import 'maplibre-gl/dist/maplibre-gl.css';

export function Demo() {
  const [center, setCenter] = createSignal<[number, number]>([-74.5, 40]);
  const [zoom, setZoom] = createSignal(9);
  return (
    <div style={{ height: '400px' }}>
      <MapLibre
        center={center()}
        onCenterChange={setCenter}
        zoom={zoom()}
        onZoomChange={setZoom}
        controls={['navigation', 'scale']}
        onClick={(e) => console.log(e.lngLat)}
      />
    </div>
  );
}

Lit

ts
import '@rozie-ui/maplibre-lit';
import 'maplibre-gl/dist/maplibre-gl.css';

// <rozie-map-libre> is a custom element. Bind `center`/`zoom` as properties
// and listen for `center-change`/`zoom-change` (the two-way change channels).
const el = document.querySelector('rozie-map-libre');
el.center = [-74.5, 40];
el.zoom = 9;
el.controls = ['navigation', 'scale'];
el.addEventListener('center-change', (e) => { el.center = e.detail; });
el.addEventListener('click', (e) => console.log(e.detail.lngLat));

API

Props

The four camera props (center / zoom / bearing / pitch) are two-way (bind with r-model / v-model / bind: / [(…)] / onXChange). All props except the construction-only bounds and fitBoundsOptions reconcile into the live map on change — no remount.

NameTypeDefaultTwo-way (model)Description
centerArray[…]The map center as [lng, lat]longitude first (MapLibre's convention, not Leaflet's [lat, lng]). Two-way: panning the map writes the new center back through the model path (echo-guarded); a consumer write easeTos the live map. The moveend echo reads getCenter() as [lng, lat].
zoomNumber1The zoom level. Two-way: scroll / pinch writes the new zoom back; a consumer write easeTos the camera. Echo-guarded against the wrapper's own programmatic moves.
bearingNumber0The map rotation (bearing) in degrees. Two-way via the rotateend echo / easeTo reconcile.
pitchNumber0The map tilt (pitch) in degrees. Two-way via the pitchend echo / easeTo reconcile.
mapStyleunknownundefinedThe map style — a StyleSpecification object or a style-URL string. Named mapStyle (not style) because style is a reserved attribute across the targets — react-map-gl and vue-maplibre-gl use the same name for the same reason. Defaults to MapLibre's official no-token demo tiles, so the component "just works" with zero config. Changing it calls setStyle and re-applies your :sources / :layers once the new style loads.
minZoomNumber0Minimum zoom level. Applied at construction and via setMinZoom on change.
maxZoomNumber22Maximum zoom level. Applied at construction and via setMaxZoom on change.
maxBoundsunknownundefinedA LngLatBoundsLike the camera is constrained to. Applied via setMaxBounds on change (pass undefined to clear).
boundsunknownundefinedConstruction-only initial fit — a LngLatBoundsLike the map fits to on mount (overrides center / zoom when set). Pair with fitBoundsOptions.
fitBoundsOptionsObject{}Construction-only options for the initial bounds fit (padding, max-zoom, etc.).
dragPanBooleantrueToggle drag-to-pan. Applied at construction and reconciled live via the handler's enable() / disable().
dragRotateBooleantrueToggle right-drag / ctrl-drag rotation. Live-reconciled.
scrollZoomBooleantrueToggle scroll-wheel zoom. Live-reconciled.
doubleClickZoomBooleantrueToggle double-click zoom. Live-reconciled.
boxZoomBooleantrueToggle shift-drag box zoom. Live-reconciled.
keyboardBooleantrueToggle keyboard navigation. Live-reconciled.
touchZoomRotateBooleantrueToggle touch pinch-zoom + rotate. Live-reconciled.
touchPitchBooleantrueToggle two-finger touch pitch. Live-reconciled.
markersArray[]The marker data that drives the reactive multi-instance marker slot — one entry per marker ({ lng, lat, id?, anchor?, offset?, draggable?, ... }). One portal handle mounts per entry; changing the array reconciles markers keep / update / dispose with no remount. Only meaningful when the marker slot is filled.
popupsArray[]The popup data that drives the reactive multi-instance popup slot — one entry per popup ({ lng, lat, id?, anchor?, offset?, closeButton?, closeOnClick?, ... }). One portal handle mounts per entry. Only meaningful when the popup slot is filled.
sourcesArray[]Declarative GeoJSON / vector / raster sources[{ id, spec }] (or a bare SourceSpecification carrying an id). Reconciled into the live style (add / setData / remove) once the style has loaded. The config-array authoring shape for sources; declarative <Source> / <Layer> children are the alternative shape (both feed the same registry).
layersArray[]Declarative layersLayerSpecification[] (each with an id). Reconciled into the live style (add / setPaintProperty / setLayoutProperty / remove) once the style has loaded; beforeId controls draw order.
interactiveLayerIdsArray[]Layer ids whose feature mouseenter / mouseleave fire the @mouseenter / @mouseleave events (populating e.features). Registered / unregistered per id on change.
controlsArray[]Standard map controls — strings ('navigation' / 'geolocate' / 'scale' / 'fullscreen' / 'attribution') or { type, position?, options? } objects. Reconciled (remove-all + re-add) on change.
optionsObject{}The raw MapOptions passthrough — spread into the Map constructor before the curated keys, so explicit props win. The MapLibre analog of an options bag for anything the curated surface doesn't special-case.

Events

MapLibre is event-ful, and the wrapper forwards 20 structured events. The four camera-lifecycle events (moveend / zoomend / rotateend / pitchend) also drive the two-way camera model; the pointer events (click / dblclick / contextmenu / mousemove / mouseenter / mouseleave) carry a structured payload ({ lngLat, point, features, originalEvent }) so the raw engine event (with its circular target: Map) is never handed to consumers.

EventPayloadFires when
loadengine eventThe map's style and initial resources finish loading.
moveengine eventThe camera is moving (fires continuously during pan / zoom).
moveendengine eventA camera move ends. Also drives the two-way center / zoom model (echo-guarded).
zoomendengine eventA zoom change ends. Drives the two-way zoom model. (The continuous zoom event is not emitted — it would collide with the zoom model prop on Vue/Angular; track zoom via r-model:zoom or zoomend.)
rotateengine eventThe bearing is changing.
rotateendengine eventA rotation ends. Drives the two-way bearing model.
pitchendengine eventA pitch change ends. Drives the two-way pitch model. (The continuous pitch event is not emitted — same pitch-model collision; track pitch via r-model:pitch or pitchend.)
dragstartengine eventA drag-pan starts.
dragengine eventThe map is being dragged.
dragendengine eventA drag-pan ends.
click{ lngLat, point, features, originalEvent }The map is clicked.
dblclick{ lngLat, point, features, originalEvent }The map is double-clicked.
contextmenu{ lngLat, point, features, originalEvent }The map is right-clicked.
mousemove{ lngLat, point, features, originalEvent }The pointer moves over the map.
mouseenter{ lngLat, point, features, originalEvent }The pointer enters a feature in an interactiveLayerIds layer (features populated).
mouseleave{ lngLat, point, features, originalEvent }The pointer leaves a feature in an interactiveLayerIds layer.
idleengine eventThe map has settled — no pending transitions, all tiles loaded.
errorengine eventThe map encountered an error (tile load failure, etc.).
styledataengine eventThe map's style data is loaded or changed.
sourcedataengine eventOne of the map's sources loads or changes.

Imperative handle

Beyond props, the component exposes imperative methods declared once in the Rozie source via $expose. Grab a handle with your framework's native ref mechanism (React useRef / Vue template ref / Svelte bind:this / Angular viewChild / Solid callback ref / the Lit custom element itself) and call them directly:

MethodDescription
getMapReturn the underlying MapLibre Map instance for direct API access (the raw-engine escape hatch). null before mount and after destroy.
flyToFly the camera to a target with an animated curve — flyTo(options) (MapLibre's CameraOptions + AnimationOptions).
easeToEase the camera to a target — easeTo(options).
jumpToJump the camera instantly (no animation) — jumpTo(options).
fitBoundsFit the camera to a bounding box — fitBounds(bounds, options?).
getCenterReturn the current center as [lng, lat] (longitude first) — normalized from MapLibre's LngLat.
getZoomReturn the current zoom level as a number.
resizeResize the map to its container (call after the container's size changes).

The camera verbs echo back into the two-way model

The $expose camera verbs (flyTo / easeTo / jumpTo / fitBounds) deliberately do not pass the wrapper's programmatic echo-guard marker, so an imperative flyTo() echoes back into the bound center / zoom / bearing / pitch model — and the prop $watch then no-ops (the camera already matches). This keeps the handle and the two-way binding consistent. The internal prop-driven reconcile does mark its moves, so a consumer state write never bounces.

The eight handle method names are clear of all three collision classes (ROZ121 / ROZ524 / Lit lifecycle): none is a React model-setter (setCenter / setZoom / setBearing / setPitch would be the auto-generated ones — none here), none is an emitted event name (move / zoom / rotate / pitch / drag / click / idle / error all differ from the verbs), and none shadows a LitElement lifecycle method (update / render / requestUpdate / …).

React example:

tsx
import { useRef } from 'react';
import { MapLibre, type MapLibreHandle } from '@rozie-ui/maplibre-react';

const map = useRef<MapLibreHandle>(null);
// <MapLibre ref={map} ... />
map.current?.flyTo({ center: [-74.5, 40], zoom: 9 });
const [lng, lat] = map.current?.getCenter() ?? [0, 0];
const raw = map.current?.getMap();   // the raw MapLibre Map instance

Slots

The wrapper surfaces three portal slots — two reactive multi-instance overlay slots (marker / popup, driven by the markers / popups props) and one mount-once control slot for a custom map control. Each is guarded — fill it and your fragments render; leave it unfilled and the surface stays absent.

Each slot's singular name (marker / popup / control) is distinct from its plural driving prop (markers / popups / controls), keeping the surface ROZ127-clean (a slot name equal to a prop key is a hard error).

SlotMounts viaRendersScope paramsKindDriven by
markernew maplibregl.Marker({ element })A framework fragment as a map markermarker, indexreactive multi-instancemarkers prop
popupnew maplibregl.Popup().setDOMContent(el)A framework fragment as a map popuppopup, indexreactive multi-instancepopups prop
controlA custom IControl host added via addControlA framework fragment as a custom map controlmapmount-once

marker and popup are reactive multi-instance slots: the wrapper mounts one portal handle per entry in the driving prop, so a single slot fill renders an unbounded number of live fragments — one per marker in markers (scope: the marker data + its index) or per popup in popups (scope: the popup data + its index). When the driving array changes, the wrapper reconciles keep / update / dispose: an existing entry's fragment re-renders in place (engine-driven { update, dispose } handle) and its engine marker / popup is moved with setLngLat, a new entry mounts a fresh fragment, and a dropped entry is disposed. No remount of the surviving fragments.

control is a mount-once portal slot: its fragment mounts once into a custom IControl host (added via addControl at top-right) and is disposed on unmount. Its scope carries the live map.

Portal slots unlock the "foreign-engine cell rendering" pattern: MapLibre owns the marker / popup / control DOM, but the consumer's framework-native fragment is mounted inside it and disposed when the engine tears it down. This is the strongest part of the wedge — Solid and Lit get framework-native marker / popup content they otherwise can't have. See the portal-slot primitive for the underlying mechanism. Each target fills #marker through its native imperative-render API:

React (render prop):

tsx
<MapLibre
  center={center}
  onCenterChange={setCenter}
  markers={[{ id: 'a', lng: -74.5, lat: 40, label: 'NYC' }]}
  renderMarker={({ marker }) => (
    <span className="pin" title={marker.label}>📍</span>
  )}
/>

Solid (render prop — note the scope is an accessor, ctx(), for fine-grained reactivity):

tsx
<MapLibre
  center={center()}
  onCenterChange={setCenter}
  markers={[{ id: 'a', lng: -74.5, lat: 40, label: 'NYC' }]}
  markerSlot={(ctx) => <span class="pin" title={ctx().marker.label}>📍</span>}
/>

Vue (scoped slot):

vue
<MapLibre v-model:center="center" :markers="markers">
  <template #marker="{ marker }">
    <span class="pin" :title="marker.label">📍</span>
  </template>
</MapLibre>

Svelte (snippet):

svelte
<MapLibre bind:center {markers}>
  {#snippet marker({ marker })}
    <span class="pin" title={marker.label}>📍</span>
  {/snippet}
</MapLibre>

Angular (content child <ng-template>):

html
<MapLibre [(center)]="center" [markers]="markers">
  <ng-template #marker let-marker="marker">
    <span class="pin" [title]="marker.label">📍</span>
  </ng-template>
</MapLibre>

Lit (slot bridge — pass the render callback as a property):

ts
const el = document.querySelector('rozie-map-libre');
el.markers = [{ id: 'a', lng: -74.5, lat: 40, label: 'NYC' }];
el.marker = ({ marker }) => html`<span class="pin" title=${marker.label}>📍</span>`;

On every target the wrapper's $portals.marker(node, { marker, index }) closure mounts the consumer's fragment into the engine-owned Marker element and returns the reactive { update, dispose } handle the wrapper calls as the marker data changes or the marker is removed. The popup and control slots mirror it exactly, with the same per-target fill API: a render prop on React (renderMarker / renderPopup / renderControl) and Solid (markerSlot / popupSlot / controlSlot — scope passed as an accessor), a scoped slot on Vue (#marker / #popup / #control), a snippet on Svelte, an <ng-template #…> content child on Angular, and an attribute: false property on Lit (.marker / .popup / .control). The popup scope is { popup, index }; the control scope is { map }.

Recipes

Markers from a data array

The marker slot renders a framework fragment as a map marker for each entry in the markers prop. It is reactive multi-instance — one portal handle per entry — so the markers track your data: add / remove entries and the wrapper mounts / disposes the matching fragments, and an entry that stays (matched by id, falling back to array index) re-renders in place while its marker is moved with setLngLat. Give each entry a stable id so a reorder doesn't churn fragments:

vue
<script setup lang="ts">
import { ref } from 'vue';
import MapLibre from '@rozie-ui/maplibre-vue';
import 'maplibre-gl/dist/maplibre-gl.css';

const center = ref<[number, number]>([-74.5, 40]);
const markers = ref([
  { id: 'nyc', lng: -74.006, lat: 40.7128, label: 'New York' },
  { id: 'bos', lng: -71.0589, lat: 42.3601, label: 'Boston' },
]);
</script>

<template>
  <div style="height: 400px">
    <MapLibre v-model:center="center" :markers="markers">
      <template #marker="{ marker }">
        <span class="pin" :title="marker.label">📍</span>
      </template>
    </MapLibre>
  </div>
</template>

Popups from a data array

The popup slot mirrors marker exactly — drive it with the popups prop (entries { lng, lat, id?, closeButton?, closeOnClick?, anchor?, offset? }) and fill the #popup scoped slot / renderPopup render prop / snippet / content-child. Each fragment is mounted into a maplibregl.Popup via setDOMContent, with the live { popup, index } in scope:

tsx
<MapLibre
  center={center}
  onCenterChange={setCenter}
  popups={[{ id: 'nyc', lng: -74.006, lat: 40.7128, title: 'New York' }]}
  renderPopup={({ popup }) => <strong>{popup.title}</strong>}
/>

Two-way camera binding

Bind any of the four camera props two-way to keep your component state and the map in sync. The wrapper echo-guards its own programmatic moves (via the eventData 2nd arg merged onto camera ops), so a consumer state write never ping-pongs:

vue
<script setup lang="ts">
import { ref } from 'vue';
import MapLibre from '@rozie-ui/maplibre-vue';

const center = ref<[number, number]>([0, 0]);
const zoom = ref(2);
const bearing = ref(0);
const pitch = ref(0);
</script>

<template>
  <button @click="center = [-0.1276, 51.5072]; zoom = 10">Fly to London</button>
  <MapLibre
    v-model:center="center"
    v-model:zoom="zoom"
    v-model:bearing="bearing"
    v-model:pitch="pitch"
  />
  <p>Center: {{ center[0].toFixed(3) }}, {{ center[1].toFixed(3) }} @ z{{ zoom.toFixed(1) }}</p>
</template>

Declarative sources & layers via :sources / :layers

Add GeoJSON / vector / raster data and styled layers through the :sources and :layers props — MapLibre's own source and layer specs. The wrapper waits for the style to load, then reconciles them into the live style (add / update / remove); changing the bound arrays applies the diff with no remount:

vue
<script setup lang="ts">
import { ref } from 'vue';
import MapLibre from '@rozie-ui/maplibre-vue';

const sources = ref([
  {
    id: 'route',
    spec: {
      type: 'geojson',
      data: { type: 'LineString', coordinates: [[-74.5, 40], [-74.0, 40.7]] },
    },
  },
]);
const layers = ref([
  { id: 'route-line', type: 'line', source: 'route', paint: { 'line-color': '#e11', 'line-width': 3 } },
]);
</script>

<template>
  <MapLibre :center="[-74.25, 40.35]" :zoom="9" :sources="sources" :layers="layers" />
</template>

Declarative <Source> / <Layer> children

Sources and layers can also be authored as declarative child components<Source> and <Layer> nested under <MapLibre>, the authoring shape the big-framework wrappers (react-map-gl, vue-maplibre-gl, svelte-maplibre-gl, ngx-maplibre-gl) are known for. Both shapes are supported, side by side with the :sources / :layers config arrays above:

html
<MapLibre :center="[-74.25, 40.35]" :zoom="9">
  <Source id="pts" :spec="geojson">
    <Layer id="circles" type="circle" :paint="{ 'circle-radius': 5 }" />
  </Source>
  <Layer id="bg" type="background" :paint="{ 'background-color': '#eef' }" />
</MapLibre>
  • Nested <Source><Layer/></Source> auto-binds the layer to its parent source via injected context — no source attr needed.
  • Flat <Layer source="id" /> directly under <MapLibre> also works, for background layers (no source) and cross-source references.
  • Both shapes coexist with :sources / :layers. A config array and declarative children feed the same id-keyed registry through the same style-load-gated addSource / addLayer reconcile; on an id collision the declarative child wins (last-writer-wins, matching the engine's own reconcile).

<Source> takes id (required) plus :spec (the SourceSpecification); <Layer> takes id (required), type, :paint / :layout, an optional source (for the flat shape), and beforeId for draw order. This dogfoods Rozie's own $provide / $inject cross-component context primitive — the map provides the registry, each child injects it and registers on mount / updates on prop change / unregisters on unmount. The big incumbents still ship deeper component catalogs (see the comparison page) — Rozie's declarative children are a curated subset that works identically on all six targets.

Hit-testing layer features

Set :interactiveLayerIds to the layer ids you want hover events on; @mouseenter / @mouseleave then fire with the hit features in the payload:

vue
<MapLibre
  :sources="sources"
  :layers="layers"
  :interactive-layer-ids="['route-line']"
  @mouseenter="(e) => (hovered = e.features[0]?.id)"
  @mouseleave="() => (hovered = null)"
/>

Driving the map from the handle

The eight $expose verbs cover the imperative surface props alone can't express. Grab the handle and call the camera verbs (flyTo / easeTo / jumpTo / fitBounds) or the readers (getCenter / getZoom), or reach the raw engine via getMap():

tsx
const map = useRef<MapLibreHandle>(null);
// <MapLibre ref={map} ... />
<button onClick={() => map.current?.flyTo({ center: [2.3522, 48.8566], zoom: 11 })}>Paris</button>
<button onClick={() => map.current?.fitBounds([[-74.3, 40.5], [-73.7, 40.9]])}>Fit NYC</button>
<button onClick={() => console.log(map.current?.getCenter())}>Log center</button>

A custom control with the control slot

Fill the control slot to mount a framework-native fragment as a custom map control (added to the top-right corner via a custom IControl). It is mount-once — its { map } scope is the live Map, so the control can drive the map directly:

vue
<MapLibre v-model:center="center">
  <template #control="{ map }">
    <button @click="map.zoomIn()">+</button>
    <button @click="map.zoomOut()">-</button>
  </template>
</MapLibre>

Gotchas

center is [lng, lat] — longitude first

The single most common porting bug: MapLibre uses [lng, lat] order everywhere — center, marker / popup setLngLat, GeoJSON coordinates — not Leaflet's [lat, lng]. getCenter() (the prop and the handle verb) returns [lng, lat] too. If your markers land in the ocean off Africa, you've swapped the order.

The camera echo-guard survives batched moves

A two-way camera binding can ping-pong: the wrapper's own easeTo fires a moveend that would echo straight back into the model. The wrapper guards this with the eventData 2nd arg — programmatic camera ops pass { rozieProgrammatic: true }, which merges onto the fired moveend / zoomend / rotateend / pitchend, and the echo handlers skip when they see it. This is more robust than a single boolean flag: it survives batched / nested camera ops with no stale-flag race. (The $expose camera verbs deliberately omit the marker so an imperative move does echo into the model — see the handle tip above.)

Import the engine CSS yourself

The component's <style> is scoped, so it cannot ship the .maplibregl-* selectors — the engine-rendered control / popup / marker DOM never carries Rozie's [data-rozie-s-*] scope attribute. You must import 'maplibre-gl/dist/maplibre-gl.css' at your app entry (or <link> the CDN copy). The wrapper's own marker / control affordances reach that engine DOM through the :root { } engine-DOM escape hatch, but the base MapLibre styles are the engine's responsibility.

Sources & layers wait for the style to load

addSource / addLayer only work after the style has loaded. The wrapper gates the :sources / :layers reconcile on isStyleLoaded() (applying once the load event fires if needed), and re-applies them after a mapStyle change (a new style wipes imperatively-added sources / layers). You don't have to sequence this yourself — bind the arrays and the wrapper handles the timing.

The container needs a height

MapLibre needs an explicitly-sized container. The wrapper's .rozie-maplibre host sets width: 100%; height: 100%; min-height: 300px, so give the parent a height (the quick-start examples wrap the map in a 400px-tall <div>). A zero-height parent renders a zero-height map.

Cross-references

Pre-v1.0 — internal monorepo.