Advanced Usage
Reactivity
Everything inside <x-map> reacts to your Livewire state. The map element uses wire:ignore.self and only the canvas MapLibre draws into is wire:ignored. When your component re-renders, Livewire morphs the child markers, routes, arcs, GeoJSON and cluster layers in place. They are added, updated or removed on the live map, and the camera stays where the user left it.
Three rules keep updates smooth:
- Don't wrap the map in wire:ignore. Older versions needed it, but now it stops children from updating.
- Give the map a stable id. A user-supplied id is rendered on the element and Livewire keys the morph on it. It is also the mapId you use to target the map.
- Use wire:key (and a stable id) on items rendered in @foreach loops so each item keeps its identity when the array changes.
use Livewire\Component; class StoreLocator extends Component { public array $stores = [ ['id' => 1, 'name' => 'Soho', 'lat' => 51.513, 'lng' => -0.136], ['id' => 2, 'name' => 'Shoreditch', 'lat' => 51.526, 'lng' => -0.078], ]; public function addStore(float $lat, float $lng): void { $this->stores[] = [ 'id' => uniqid(), 'name' => 'New store', 'lat' => $lat, 'lng' => $lng, ]; } public function removeLast(): void { array_pop($this->stores); } }
<div> <button wire:click="removeLast">Remove last store</button> <!-- No wire:ignore: the markers below update in place --> <x-map id="stores-map" :center="[-0.1, 51.515]" :zoom="12" height="400px"> @foreach ($stores as $store) <x-map-marker wire:key="store-{{ $store['id'] }}" :id="'store-'.$store['id']" :lat="$store['lat']" :lng="$store['lng']" > <x-marker-tooltip :text="$store['name']" /> </x-map-marker> @endforeach </x-map> </div>
Livewire Interactivity
The package dispatches events to Livewire and listens for specific commands, so you can drive the map from your PHP component.
Events Dispatched to Livewire
Each event is dispatched once as a bubbling DOM CustomEvent that Livewire also receives. That means Alpine (@map:click) and Livewire (#[On('map:click')]) listeners both get it exactly once. Every payload also includes mapId, the id of the map that fired it.
use Livewire\Attributes\On; #[On('map:marker-clicked')] public function markerClicked(string $id, float $lat, float $lng, ?string $mapId = null): void { $this->selectedStore = $id; }
Map events
| Event | Payload | Description |
|---|---|---|
| map:loaded | center, zoom | The map is created and its first style has loaded |
| map:style-loaded | style, dark | A style finished loading (also after theme switches) |
| map:style-reloaded | — | The style was replaced via map:set-style |
| map:theme-changed | dark | The resolved light/dark theme changed (theme="auto") |
| map:click | lat, lng, lngLat, point | User clicked on the map |
| map:double-click | lat, lng | User double-clicked on the map |
| map:right-click | lat, lng | User right-clicked (context menu) on the map |
| map:move | lat, lng | Fired continuously while the map moves (throttled) |
| map:center-changed | lat, lng | Fired once movement ends |
| map:bounds-changed | north, south, east, west | Visible bounds after movement ends |
| map:viewport-changed | center, zoom, bearing, pitch | Full camera state after movement ends; center is [lng, lat] |
| map:zoom | zoom | Fired continuously while zooming (throttled) |
| map:zoom-changed | zoom | Fired once zooming ends |
| map:drag-end | lat, lng, zoom | User finished dragging the map |
| map:bearing-changed | bearing | The map was rotated (throttled) |
| map:pitch-changed | pitch | The map was tilted (throttled) |
| map:3d-toggled | enabled | The 3D button of <x-map-controls :three-d="true"> was toggled |
| map:locate-success | lat, lng, accuracy | The locate control found the user |
| map:locate-error | code, message | Geolocation failed or was denied |
| map:{event} | MapLibre event data | Extra MapLibre events forwarded via custom_events / :events |
Marker & popup events
| Event | Payload | Description |
|---|---|---|
| map:marker-clicked | id, lat, lng | A marker was clicked |
| map:marker-mouseenter | id, lat, lng | Pointer entered a marker |
| map:marker-mouseleave | id | Pointer left a marker |
| map:marker-drag-start | id, lat, lng | Dragging a draggable marker started |
| map:marker-drag | id, lat, lng | Fired while a marker is dragged (throttled) |
| map:marker-drag-end | id, lat, lng | A draggable marker was released |
| map:marker-popup-open | id | A marker popup opened |
| map:marker-popup-close | id | A marker popup closed |
| map:popup-open | id | A standalone <x-map-popup> opened |
| map:popup-close | id | A standalone popup closed |
Route events
| Event | Payload | Description |
|---|---|---|
| map:route-clicked | id, lat, lng, coordinates, point | A route line was clicked |
| map:route-mouseenter | id | Pointer entered a route |
| map:route-mouseleave | id | Pointer left a route |
| map:route-directions-ready | id, geometry, distance, duration, alternatives | Directions were fetched from OSRM |
| map:route-directions-error | id, message | Fetching directions failed (falls back to straight lines) |
| map:route-alternative-selected | id, alternativeIndex, previousIndex, geometry, distance, duration | An alternative route became the primary route |
| map:route-updated | id | A map:update-route-data-{id} command was applied |
| map:route-group-selection-changed | groupId, selectedRouteId, selectedIndex, previousRouteId, previousIndex, distance, duration | The selected route of a route group changed |
| map:route-group-directions-ready | groupId, routes | All routes of a group were resolved |
| map:route-list-selected | routeId, selectedIndex, previousIndex, distance, duration | A route was picked in <x-map-route-list> (no mapId) |
Arc, GeoJSON & cluster events
| Event | Payload | Description |
|---|---|---|
| map:arc-clicked | id, arc, lat, lng | An arc was clicked; arc is the original datum |
| map:arc-hovered | id, arc, lat, lng | Pointer moved onto an arc; on leave arc is null |
| map:geojson-clicked | id, feature, lat, lng | A feature was clicked; feature is {id, properties, geometry} |
| map:geojson-hovered | id, feature, lat, lng | Pointer moved onto a feature; on leave feature is null |
| map:cluster-clicked | cluster_id, lat, lng, count | A cluster bubble was clicked |
| map:cluster-expanded | cluster_id, zoom | The map zoomed in to expand a cluster |
| map:cluster-point-clicked | feature_id, lat, lng, properties | An unclustered point was clicked |
Commands from Livewire
Trigger these from your Livewire component using $this->dispatch(). You can pass the payload as named arguments or as a single array.
Map commands
Each accepts an optional mapId. Without it the command applies to every map on the page.
| Command | Payload | Description |
|---|---|---|
| map:fly-to | center, zoom, bearing, pitch, … | Animate the camera along a flight path (MapLibre flyTo options) |
| map:ease-to | center, zoom, bearing, pitch, … | Animate the camera smoothly (MapLibre easeTo options) |
| map:jump-to | center, zoom, bearing, pitch, … | Move the camera instantly (MapLibre jumpTo options) |
| map:fit-bounds | bounds, padding, … | Fit [[west, south], [east, north]] in view; padding defaults to 40 |
| map:set-zoom | zoom | Set the zoom level |
| map:set-center | center | Set the center ([lng, lat]) |
| map:set-bearing | bearing | Set the rotation in degrees |
| map:set-pitch | pitch | Set the tilt in degrees |
| map:set-style | style | Replace the style (URL or style JSON); fires map:style-reloaded |
| map:set-theme | theme | Switch to light, dark or auto |
| map:resize | — | Recalculate the map size (after showing a hidden container) |
| map:call | method, args | Call any public MapLibre Map method, e.g. method: 'setMaxZoom', args: [12] |
Component commands
These target a component by the id you gave it, so give dynamic routes, arcs, GeoJSON and cluster layers an explicit id.
| Command | Payload | Description |
|---|---|---|
| map:update-route-data-{id} | coordinates, color, width, progress, progressDuration, … | Update a route's coordinates and/or style |
| map:route-progress-{id} | progress, duration | Animate the progress overlay to progress (0–1) over duration ms |
| map:select-route-{id} | index | Make an alternative route the primary one |
| map:force-animate | id?, duration? | Replay the draw animation of one route/group, or of all animated ones |
| map:update-route-group-{id} | selectedRoute, routes | Change the selected route or patch route styles in a group |
| map:update-arc-data-{id} | data (or a plain array) | Replace the arcs of an <x-map-arc> |
| map:update-geojson-{id} | data (or a GeoJSON object) | Replace the data of an <x-map-geojson> |
| map:update-cluster-data-{id} | data (or a FeatureCollection) | Replace the points of a cluster layer (send GeoJSON, e.g. GeoJSON::fromArray($points)) |
Targeting a Map
When a page has more than one map, pass mapId with a command to control only that map. Map commands without mapId apply to every map on the page. Outbound events carry the same mapId, so a listener can tell which map fired them.
use Livewire\Attributes\On; public function showLondon(): void { // Only the map with id="main-map" moves $this->dispatch('map:fly-to', mapId: 'main-map', center: [-0.09, 51.5], zoom: 12); } #[On('map:click')] public function mapClicked(float $lat, float $lng, ?string $mapId = null): void { if ($mapId !== 'main-map') { return; // ignore clicks on the overview map } $this->addStore($lat, $lng); }
<x-map id="main-map" height="400px" /> <x-map id="overview-map" height="200px" :interactive="false" />
Example: Fly To Location
Smoothly animate the map to a new location from your Livewire component:
use Livewire\Component; class FlightTracker extends Component { public function centerOnPilot() { $this->dispatch('map:fly-to', mapId: 'flight-map', center: [-0.09, 51.5], zoom: 12, essential: true, ); } public function render() { return view('livewire.flight-tracker'); } }
<div> <button wire:click="centerOnPilot"> Center on Pilot </button> <x-map id="flight-map" :center="[-0.09, 51.5]" :zoom="8" height="400px" > <x-map-controls /> </x-map> </div>
Theme Awareness
With theme="auto" (the default) the map follows a dark/light class or data-theme attribute on your <html> element, and fires map:theme-changed when it switches. You can also force a specific theme, or change it at runtime with the map:set-theme command:
theme="light"
theme="dark"
Controlled Viewport
Track and control the map viewport (center, zoom, bearing, pitch) by listening to events and using Alpine.js for real-time reactivity. To sync the camera to Livewire, listen to map:viewport-changed. It fires once per movement with the full camera state, so it doesn't flood the server.
#[On('map:viewport-changed')] public function viewportChanged(array $center, float $zoom, float $bearing, float $pitch): void { [$this->lng, $this->lat] = $center; $this->zoom = $zoom; }
Viewport Event Reference
| Event | Detail | Description |
|---|---|---|
| map:move | lat, lng | Fired continuously as the map moves |
| map:center-changed | lat, lng | Fired once movement ends |
| map:bounds-changed | north, south, east, west | Fired once movement ends |
| map:viewport-changed | center, zoom, bearing, pitch | Full camera state, fired once movement ends |
| map:zoom | zoom | Fired continuously during zoom |
| map:zoom-changed | zoom | Fired once zoom ends |
| map:bearing-changed | bearing | Fired when the map is rotated |
| map:pitch-changed | pitch | Fired when the map is tilted |
Example: Fit Bounds
Fit the map to show all your markers. padding defaults to 40, and any other MapLibre fitBounds option (e.g. maxZoom, duration) is passed through:
public function showAllLocations() { $this->dispatch('map:fit-bounds', [ 'bounds' => [ [-0.15, 51.49], // Southwest corner [lng, lat] [-0.01, 51.54], // Northeast corner [lng, lat] ], 'padding' => 50, ]); }
Example: Set Zoom
Programmatically change the zoom level:
public function zoomIn() { $this->dispatch('map:set-zoom', [ 'zoom' => 16, ]); }
Example: Resize
The map resizes itself when its own element changes size. If its container was hidden when the page loaded (tabs, accordions, modals), force a recalculation once it's visible:
public function onTabChanged() { $this->dispatch('map:resize'); }
The $map Magic
Inside any markup in an <x-map>, the Alpine magic $map returns the MapLibre Map instance of the nearest enclosing map. That gives you the full MapLibre API from a button, with no JavaScript file and no server round-trip. It pairs well with <x-map-control> for custom buttons. $map is null until the map has been created.
JavaScript API
For code outside the map markup (your own scripts, other Alpine components), the package exposes window.LivewireMap:
| Member | Returns | Description |
|---|---|---|
| getMap(id) | Map | null | The MapLibre instance of the map with that id, or null if it doesn't exist (yet) |
| getRoute(id) | controller | null | The controller of the <x-map-route> with that id |
| waitForMap(id, callback) | — | Calls callback(map) once the map exists and its style has loaded. Works across wire:navigate. Pass null as the id for the first map on the page |
| maps | object | Registry of all maps, keyed by id |
| version | string | The package's JavaScript version |
Route controller
| Method | Returns | Description |
|---|---|---|
| setProgress(value, durationMs = 0) | — | Set the progress overlay (0–1, or null to clear), animated over durationMs |
| selectRoute(index) | — | Make an alternative route the primary one |
| animate(duration?) | — | Replay the draw animation |
| pointAt(at) | [lng, lat] | null | Point at 'start', 'end', 'progress' or a 0–1 fraction |
| fitBounds(options?) | — | Fit the map to the route |
// Run code once the map is ready (also after wire:navigate) LivewireMap.waitForMap('main-map', (map) => { map.addControl(new maplibregl.AttributionControl({ compact: true })); }); // Move a delivery along its route const route = LivewireMap.getRoute('delivery'); route?.setProgress(0.75, 1500); const [lng, lat] = route?.pointAt('progress') ?? [];
Alpine.js Directives
x-map-resize resizes the enclosing map whenever its expression becomes truthy. It's useful for maps in tabs, modals and accordions that toggle on the client. For other camera actions use $map (see above).
| Directive | Value | Description |
|---|---|---|
| x-map-resize | expression | Resize the enclosing map each time the expression becomes truthy |
<div x-data="{ open: false }"> <button x-on:click="open = !open">Toggle map</button> <div x-show="open"> <x-map height="300px"> <div x-map-resize="open"></div> </x-map> </div> </div>
Custom MapLibre Events
Forward any other MapLibre event as map:{event}. Set it for all maps with the custom_events config key, or per map with the :events prop (the two are merged):
'custom_events' => ['idle', 'sourcedata'],
<x-map :events="['idle', 'rotatestart']" @map:idle="console.log('map is idle')" />
GeoJSON Helper
The package includes a support class that converts PHP arrays (items with lat/lng keys, or GeoJSON Features) into a GeoJSON FeatureCollection:
use Kwasii\LivewireMapcn\Support\GeoJSON; $data = [ ['name' => 'London', 'lat' => 51.5, 'lng' => -0.1], ['name' => 'Paris', 'lat' => 48.8, 'lng' => 2.3], ]; $geojson = GeoJSON::fromArray($data); // Returns a valid GeoJSON array structure
Performance Tips
Clustering Precision
Tune :cluster-radius (higher is faster) and :cluster-max-zoom to balance performance and detail.
Simplify Geometry
Increase :tolerance (e.g., 1.0) to simplify GeoJSON geometry or :buffer to reduce off-screen flashing.
Throttled Events
Prefer end-of-gesture events (map:viewport-changed, map:zoom-changed) for Livewire listeners, and use Alpine's .throttle or .debounce for continuous events like @map:move.
Dynamic Data
For large, frequently changing datasets, push data with map:update-cluster-data-{id}, map:update-geojson-{id} or map:update-arc-data-{id} from a #[Renderless] action instead of re-rendering the component.