MaplibreX
MapLibre GL JS as declarative Phoenix LiveView components.
Build interactive maps with assigns and handle_event/3 instead of hand-written
JavaScript.
Inspired by svelte-maplibre.
def render(assigns) do
~H"""
<.map id="map" center={@center} zoom={@zoom} class="h-96 w-full" />
<.navigation_control id="nav" map_id="map" position="top-left" />
<.marker :for={city <- @cities} id={city.id} map_id="map"
lng_lat={city.coords} color="#22d3ee" popup_text={city.name} />
"""
end
def handle_event("map:moved", %{"center" => center, "zoom" => zoom}, socket) do
{:noreply, assign(socket, center: center, zoom: zoom)}
end
Why
- Declarative — components and assigns, not imperative JS glue.
- Reactive — the map follows your assigns; events flow back into
handle_event/3. - Small — the published bundle is ~65 KB (12 KB gzipped). MapLibre GL is a peer dependency, so there is never a second copy of it on the page.
- Complete — 32 components covering layers, sources, controls, 3D terrain, deck.gl and custom WebGL layers.
- Typed — the hook layer is written in TypeScript, type-checked in CI.
- Tested — 405 tests against the rendered component output.
Installation
Requires Elixir 1.15+ and Phoenix LiveView 1.0+.
1. Add the dependency
def deps do
[
{:maplibrex, "~> 0.1.0"}
]
end
2. Install the JavaScript peer dependencies
MaplibreX does not bundle MapLibre GL — your application owns that version.
npm install --prefix assets maplibre-gl
Only if you plan to use <.deckgl_layer> (these are lazy-loaded at runtime, so
skip them otherwise):
npm install --prefix assets @deck.gl/core @deck.gl/layers \
@deck.gl/aggregation-layers @deck.gl/mapbox
Which MapLibre GL version?
MaplibreX supports >=5.0.0 <7.0.0.
| maplibre-gl v5 | maplibre-gl v6 | |
|---|---|---|
Every component except deckgl_layer | ✅ | ✅ |
<.deckgl_layer> | ✅ | ❌ |
deck.gl is the single exception: @deck.gl/mapbox reads MapLibre's internal
map.transform, which v6 removed. Every published version, including the 9.4
alphas, still does. If you use <.deckgl_layer>, pin maplibre-gl to
^5.0.0; MaplibreX raises a message saying exactly this rather than letting
deck.gl fail with an opaque error.
Note that maplibre-gl v6 is ESM-only, so your app.js must be loaded as
<script type="module">.
3. Register the hooks
In assets/js/app.js:
import { MapHooks } from "maplibrex"
let liveSocket = new LiveSocket("/live", Socket, {
hooks: { ...MapHooks },
params: { _csrf_token: csrfToken }
})
The bare "maplibrex" import resolves through NODE_PATH, the same mechanism
Phoenix already uses for phoenix and phoenix_live_view. A generated Phoenix
app has this in config/config.exs already — confirm the env: line is there:
config :esbuild,
version: "0.25.4",
my_app: [
args: ~w(js/app.js --bundle --target=es2022 --outdir=../priv/static/assets),
cd: Path.expand("../assets", __DIR__),
env: %{"NODE_PATH" => Path.expand("../deps", __DIR__)}
]
If you would rather not rely on NODE_PATH, import the bundle by path:
import { MapHooks } from "../../deps/maplibrex/priv/static/assets/js/maplibrex.js"
4. Import the stylesheet
In assets/css/app.css:
@import "maplibre-gl/dist/maplibre-gl.css";
@import "../../deps/maplibrex/assets/css/maplibrex.css";
5. Import the components
defmodule MyAppWeb.MapLive do
use MyAppWeb, :live_view
import MaplibreX.Components
# ...
end
Configuration
Optional defaults, in config/config.exs:
config :maplibrex,
default_style: "https://demotiles.maplibre.org/style.json",
default_center: [0, 0],
default_zoom: 10
To see MaplibreX's lifecycle logging in the browser console, set
window.__MAPLIBREX_DEBUG__ = true before the bundle loads, or pass
debug={true} to a <.map>. Warnings and errors are always logged.
Components
Core — map
Overlays — marker, popup
Layers — geojson_layer, circle_layer, line_layer, fill_layer,
symbol_layer, heatmap_layer, fill_extrusion_layer, background_layer,
hillshade_layer, raster_layer
Sources — vector_tile_source, raster_tile_source, raster_dem_source,
image_source, video_source
Controls — navigation_control, scale_control, fullscreen_control,
geolocate_control, attribution_control, terrain_control, control,
control_button, control_group, zoom_range
3D & terrain — terrain, sky
Advanced — deckgl_layer, custom_layer
Every component is documented with attributes, events and examples on HexDocs.
Events
Map events arrive in your LiveView as ordinary handle_event/3 calls:
| Event | Payload |
|---|---|
map:loaded | %{"mapId" => id} |
map:moved | %{"center" => [lng, lat], "zoom" => z, "bearing" => b, "pitch" => p} |
map:clicked | %{"lngLat" => [lng, lat], "point" => [x, y]} |
map:zoom_changed | %{"zoom" => z} |
map:error | %{"error" => message} |
marker:clicked | %{"markerId" => id, "lngLat" => [lng, lat]} |
marker:drag_start / marker:dragging / marker:drag_end | %{"markerId" => id, "lngLat" => [lng, lat]} |
layer:feature_click | %{"layerId" => id, "feature" => feature} |
map:moved is debounced by 150 ms so continuous panning does not flood the
socket.
Controlling the map
Map commands are Phoenix.LiveView.JS structs, so they run entirely on the
client with no server round-trip:
alias MaplibreX.Components.Map
<button phx-click={Map.fly_to("map", [-74.5, 40], 12)}>Fly to NYC</button>
<button phx-click={Map.zoom_in("map")}>Zoom in</button>
<button phx-click={Map.fit_bounds("map", [[-74, 40], [-73, 41]], padding: 50)}>Fit</button>
Available: fly_to/4, jump_to/4, fit_bounds/3, set_style/2, zoom_in/1,
zoom_out/1, reset_north/1.
Demo
A full Phoenix application exercising every component: maplibrex_demo
Development
mix setup # deps + npm install
mix test # 405 tests
mix typecheck # tsc --noEmit
mix ci # format check, warnings-as-errors, credo, tests
mix assets.build # development bundle
mix assets.watch # rebuild on change
Publishing (the alias builds priv/static first — never run mix hex.publish
directly, or the package ships without its JavaScript):
mix publish
Run it in :dev: hex.publish builds the documentation, and ex_doc is a
dev-only dependency. The release bundle is built by its own esbuild profile,
so the output is identical whatever MIX_ENV is set to.
Contributing
See CONTRIBUTING.md. Bug reports and pull requests are welcome.
License
MIT — see LICENSE.