38 KiB
leaflet-components
Leaflet.js as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object with reactive attribute binding.
Installation
npm install leaflet-components
Import options
The package is ESM-only — tsc emits dist/ as individual modules, one per component, with no bundling step. Deep imports work for registering only what you use.
// Registers all components
import 'leaflet-components';
// Deep import — register only one component
import 'leaflet-components/dist/components/leaflet-marker.js';
leaflet is always an external dependency — you must install it yourself. There is no CommonJS or UMD build.
Usage
Import once to register all custom elements, then use them declaratively in HTML.
<script type="module">
import 'leaflet-components';
</script>
<leaflet-map lat="51.505" lng="-0.09" zoom="13" style="height:400px">
<leaflet-tile-layer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution="© OpenStreetMap contributors"
></leaflet-tile-layer>
<leaflet-marker lat="51.505" lng="-0.09">
<leaflet-popup><b>Hello!</b></leaflet-popup>
<leaflet-tooltip>Hover me</leaflet-tooltip>
</leaflet-marker>
<leaflet-circle
lat="51.508"
lng="-0.11"
radius="500"
color="red"
fill-color="#f03"
fill-opacity="0.5"
>
<leaflet-popup>I am a circle</leaflet-popup>
</leaflet-circle>
<leaflet-polygon color="blue">
<leaflet-line lat="51.509" lng="-0.08"></leaflet-line>
<leaflet-line lat="51.503" lng="-0.06"></leaflet-line>
<leaflet-line lat="51.510" lng="-0.047"></leaflet-line>
</leaflet-polygon>
<leaflet-control-scale position="bottomleft"></leaflet-control-scale>
</leaflet-map>
leaflet-map
The root component. All other components must be children of <leaflet-map>.
<leaflet-map lat="51.505" lng="-0.09" zoom="13" disable-scroll-wheel-zoom></leaflet-map>
View state
These attributes stay in sync with the map as the user interacts with it — panning updates lat/lng, zooming updates zoom.
| Attribute | Default | Description |
|---|---|---|
lat |
0 |
Center latitude |
lng |
0 |
Center longitude |
zoom |
2 |
Zoom level |
min-zoom |
0 |
Minimum zoom level |
max-zoom |
— | Maximum zoom level. When unset, Leaflet uses the tile layer's own max zoom. |
Interaction
Boolean options that default to true are controlled by the presence of a disable-* attribute. Handler-based options can be toggled at any time; the rest only apply at construction.
| Attribute | Controls | Live? |
|---|---|---|
disable-dragging |
Mouse/touch panning | ✓ |
disable-scroll-wheel-zoom |
Scroll-wheel zoom | ✓ |
disable-double-click-zoom |
Double-click zoom | ✓ |
disable-touch-zoom |
Pinch-to-zoom | ✓ |
disable-box-zoom |
Shift-drag zoom box | ✓ |
disable-keyboard |
Keyboard pan/zoom | ✓ |
disable-zoom-control |
Built-in zoom control | — |
disable-attribution-control |
Built-in attribution | — |
disable-close-popup-on-click |
Close popup on map click | — |
disable-track-resize |
Auto-resize on window resize | — |
disable-bounce-at-zoom-limits |
Bounce animation at min/max zoom | — |
disable-tap-hold |
Long-press context menu (mobile) | — |
Boolean options that default to false are enabled by adding the attribute:
| Attribute | Description |
|---|---|
prefer-canvas |
Render vector layers on Canvas instead of SVG |
world-copy-jump |
Pan to the original world copy when crossing the antimeridian |
Animation
| Attribute | Default | Description |
|---|---|---|
disable-zoom-animation |
— | Disable CSS zoom animation |
disable-fade-animation |
— | Disable tile fade-in |
disable-marker-zoom-animation |
— | Disable marker zoom animation |
zoom-animation-threshold |
4 |
Max zoom delta for animated zoom |
Inertia & panning
| Attribute | Default | Description |
|---|---|---|
disable-inertia |
— | Disable inertial panning |
inertia-deceleration |
3000 |
Deceleration rate (px/s²) |
inertia-max-speed |
Infinity |
Maximum inertia speed (px/s) |
ease-linearity |
0.2 |
Pan easing linearity |
Zoom behaviour
| Attribute | Default | Description |
|---|---|---|
zoom-snap |
1 |
Zoom snapping interval; 0 for continuous zoom |
zoom-delta |
1 |
Zoom step per keyboard/button press |
max-bounds-viscosity |
0 |
How much the bounds resist panning past them (0–1) |
Scroll wheel
| Attribute | Default | Description |
|---|---|---|
wheel-debounce-time |
40 |
Debounce delay for wheel events (ms) |
wheel-px-per-zoom-level |
60 |
Pixels of scroll per zoom level |
Keyboard & touch
| Attribute | Default | Description |
|---|---|---|
keyboard-pan-delta |
80 |
Pan distance per key press (px) |
tap-tolerance |
15 |
Max touch movement to trigger a tap (px) |
Rendering
| Attribute | Default | Description |
|---|---|---|
transform-3d-limit |
8388608 |
Max CSS translate3d component value before a layer reset |
Accessing the underlying map
const el = document.querySelector('leaflet-map');
el.leafletObject; // L.Map instance (undefined before connected)
el.zoom; // current zoom (reads live from map, falls back to attribute)
el.scrollWheelZoom = false; // disable at runtime
All leaflet-map properties are two-way: reading returns the live map value; writing updates both the attribute and the map.
Escape hatch
Every component exposes a leafletObject getter that returns the underlying Leaflet instance. This is useful when you need to call Leaflet API methods directly:
const marker = document.querySelector('leaflet-marker');
marker.leafletObject?.setLatLng([51.5, -0.09]);
const map = document.querySelector('leaflet-map');
map.leafletObject?.flyTo([48.86, 2.35], 13);
Returns undefined while the element isn't connected to the DOM.
CSS
Leaflet's stylesheet is loaded from a CDN via a <link> in the shadow DOM. Marker icon paths are derived from the CSS URL automatically.
| Attribute | Default | Description |
|---|---|---|
css-url |
https://unpkg.com/leaflet@1.9.4/dist/leaflet.css |
URL for Leaflet CSS |
css-integrity |
sha256-... |
SRI hash (required with default URL; when css-url is custom, no default integrity is used unless explicitly set) |
css-crossorigin |
(auto) | "anonymous" when integrity is active, otherwise absent. Set explicitly to override. |
Resize
A ResizeObserver on the host element automatically calls map.invalidateSize() whenever the element's dimensions change — from CSS classes, inline styles, attribute changes, or parent layout.
Events
Every event the underlying Leaflet object fires is re-emitted on the element as a leaflet:<type> DOM event — zoomend becomes leaflet:zoomend, popupopen becomes leaflet:popupopen, and so on. This is generic and automatic for every component; nothing needs to be configured per event.
const map = document.querySelector('leaflet-map');
map.addEventListener('leaflet:moveend', (e) => {
console.log(e.detail.target.getCenter());
});
const marker = document.querySelector('leaflet-marker');
marker.addEventListener('leaflet:dragend', (e) => {
console.log('dragged', e.detail.distance, 'px');
});
event.detail is the same object a native Leaflet .on() listener receives — it always carries type, target, and sourceTarget in addition to whatever fields that particular event adds (latlng for mouse events, popup for popupopen/popupclose, distance for dragend, etc.). These events don't bubble: Leaflet already propagates layer events up to the map internally, so a bubbling DOM event would make <leaflet-map> see each one twice.
Which events a component can fire depends on the Leaflet class it wraps — see Leaflet's own event reference for the full list per class (Map, Layer, Marker, Path, Popup, TileLayer, etc.).
TypeScript
leaflet-* elements are typed. document.createElement/querySelector infer the right component class for every tag:
const map = document.querySelector('leaflet-map'); // LeafletMap | null
addEventListener is typed per component against the real Leaflet event payload for events that component actually fires — ordinary DOM events (click, etc.) still work normally, and an event name the component doesn't fire is a compile error:
marker.addEventListener('leaflet:dragend', (e) => {
e.detail.distance; // number — DragEndEvent
});
// Type error: baselayerchange is only fired by <leaflet-map>, not <leaflet-marker>.
marker.addEventListener('leaflet:baselayerchange', () => {});
There's no generic fallback overload for leaflet:* names — that's deliberate, so a typo or a wrong event name doesn't silently type-check. A genuinely dynamic (non-literal) event name string needs a cast.
Components
<leaflet-tile-layer>
| Attribute | Default | Description |
|---|---|---|
url |
'' |
Tile URL template (https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png) |
attribution |
'' |
Attribution text |
min-zoom |
0 |
Minimum zoom level |
max-zoom |
18 |
Maximum zoom level |
opacity |
1.0 |
Tile layer opacity |
z-index |
1 |
Z-index |
subdomains |
'abc' |
Subdomains for {s} in the URL template |
tms |
— | Use TMS tile coordinate scheme |
zoom-offset |
0 |
Offset added to the zoom level when requesting tiles |
zoom-reverse |
— | Reverse the zoom level when requesting tiles |
detect-retina |
— | Request higher-resolution tiles on retina displays |
cross-origin |
'' |
CORS setting for tile images |
referrer-policy |
— | referrerPolicy for tile image requests |
error-tile-url |
'' |
Fallback tile image on load error |
tile-size |
256 |
Tile size in pixels |
no-wrap |
— | Don't wrap tiles horizontally across the antimeridian |
bounds |
'' |
Restrict tile loading to this bounding box, as JSON: [[south,west],[north,east]] |
class-name |
'' |
CSS class for tile images |
min-native-zoom |
— | Lowest zoom level the tile source natively supports |
max-native-zoom |
— | Highest zoom level the tile source natively supports |
keep-buffer |
2 |
Extra rows/columns of tiles to keep loaded outside the viewport |
update-when-idle |
— | Update tiles only when the map stops moving |
update-when-zooming |
true (toggle with update-when-zooming="false") |
Update tiles continuously while zooming |
update-interval |
200 |
Throttle between tile updates while panning (ms) |
pane |
'tilePane' |
Map pane name |
Most of these apply only at construction — Leaflet exposes no setter for them, so changing the attribute afterward has no effect. min-zoom, max-zoom, opacity, and z-index are live.
<leaflet-tile-layer-wms>
All <leaflet-tile-layer> attributes above apply (a WMS layer is a tile layer), plus:
| Attribute | Default | Description |
|---|---|---|
url |
'' |
WMS service URL |
layers |
'' |
Comma-separated layer names |
styles |
'' |
Comma-separated style names |
format |
'image/jpeg' |
Image format |
transparent |
— | Request transparent tiles |
version |
'1.1.1' |
WMS version |
uppercase |
— | Use uppercase WMS parameter names |
crs |
— | Coordinate reference system by name: 'EPSG3857', 'EPSG4326', 'EPSG3395', or 'Simple' |
<leaflet-marker>
| Attribute | Default | Description |
|---|---|---|
lat |
0 |
Latitude |
lng |
0 |
Longitude |
title |
'' |
Tooltip text on hover |
alt |
'' |
Alt text for the marker image |
draggable |
— | Allow dragging the marker |
opacity |
1.0 |
Marker opacity |
z-index-offset |
0 |
Z-index offset |
A <leaflet-icon> or <leaflet-div-icon> child sets the marker's icon (see below); without one, Leaflet's default icon is used.
<leaflet-icon>
Sets the marker icon to an image. Not rendered itself — swapped into a parent <leaflet-marker> via setIcon() whenever an attribute changes. Produces no icon (Leaflet's default is used instead) until icon-url is set.
| Attribute | Default | Description |
|---|---|---|
icon-url |
'' |
Icon image URL (required) |
icon-retina-url |
'' |
Higher-resolution icon image URL |
icon-size |
— | Icon size as JSON: [width, height] |
icon-anchor |
— | Point of the icon aligned to the marker's location, as JSON: [x, y] |
popup-anchor |
— | Popup anchor point relative to icon-anchor, as JSON: [x, y] |
tooltip-anchor |
— | Tooltip anchor point relative to icon-anchor, as JSON: [x, y] |
shadow-url |
'' |
Shadow image URL |
shadow-retina-url |
'' |
Higher-resolution shadow image URL |
shadow-size |
— | Shadow size as JSON: [width, height] |
shadow-anchor |
— | Shadow anchor point, as JSON: [x, y] |
class-name |
'' |
CSS class for the icon image |
<leaflet-div-icon>
Sets the marker icon to an HTML element instead of an image. Content comes from innerHTML (or the html attribute if no markup is given) — either way it's rebuilt on every change, the same as <leaflet-icon>.
| Attribute | Default | Description |
|---|---|---|
icon-size |
— | Icon size as JSON: [width, height] |
icon-anchor |
— | Point of the icon aligned to the marker's location, as JSON: [x, y] |
popup-anchor |
— | Popup anchor point relative to icon-anchor, as JSON: [x, y] |
tooltip-anchor |
— | Tooltip anchor point relative to icon-anchor, as JSON: [x, y] |
class-name |
'leaflet-div-icon' |
CSS class for the icon element |
html |
'' |
HTML content, if not using innerHTML |
bg-pos |
— | Background position offset, as JSON: [x, y] |
<leaflet-circle>
All path style attributes apply.
| Attribute | Default | Description |
|---|---|---|
lat |
0 |
Center latitude |
lng |
0 |
Center longitude |
radius |
1000 |
Radius in meters |
<leaflet-circle-marker>
All path style attributes apply.
| Attribute | Default | Description |
|---|---|---|
lat |
0 |
Center latitude |
lng |
0 |
Center longitude |
radius |
10 |
Radius in pixels |
<leaflet-polyline>
Coordinates are taken from child <leaflet-line> elements, not from attributes. All path style attributes apply, except fill defaults to unfilled (a polyline isn't a closed shape, but it can still be filled explicitly).
| Attribute | Default | Description |
|---|---|---|
smooth-factor |
1.0 |
Simplification factor applied while panning/zooming — higher is more simplified |
no-clip |
— | Disable polyline clipping |
<leaflet-polygon>
Coordinates are taken from child <leaflet-line> elements, not from attributes. All path style attributes apply.
<leaflet-rectangle>
All path style attributes apply.
| Attribute | Default | Description |
|---|---|---|
bounds |
'' |
Bounding box as JSON: [[south,west],[north,east]] |
<leaflet-line>
Vertex helper — not rendered directly. Used as a child of <leaflet-polyline> or <leaflet-polygon>.
| Attribute | Default | Description |
|---|---|---|
lat |
0 |
Latitude |
lng |
0 |
Longitude |
<leaflet-image-overlay>
| Attribute | Default | Description |
|---|---|---|
url |
'' |
Image URL |
bounds |
'' |
Bounding box as JSON: [[south,west],[north,east]] |
opacity |
1.0 |
Overlay opacity |
alt |
'' |
Alt text |
interactive |
— | Receive mouse/touch events |
cross-origin |
'' |
CORS setting for the image |
error-overlay-url |
'' |
Fallback image on load error |
z-index |
0 |
Z-index |
class-name |
'' |
CSS class for the image element |
<leaflet-video-overlay>
| Attribute | Default | Description |
|---|---|---|
url |
'' |
Video URL |
bounds |
'' |
Bounding box as JSON: [[south,west],[north,east]] |
opacity |
1.0 |
Overlay opacity |
alt |
'' |
Alt text |
interactive |
— | Receive mouse/touch events |
cross-origin |
'' |
CORS setting for the video |
loop |
— | Loop playback |
autoplay |
— | Start playing automatically |
muted |
— | Mute audio |
playsinline |
— | Play inline (mobile) |
z-index |
0 |
Z-index |
class-name |
'' |
CSS class for the video element |
keep-aspect-ratio |
true (toggle with keep-aspect-ratio="false") |
Preserve the video's aspect ratio within its bounds |
error-overlay-url |
'' |
Fallback image on load error |
<leaflet-svg-overlay>
Content comes from an inline <svg> child element. No url attribute.
| Attribute | Default | Description |
|---|---|---|
bounds |
'' |
Bounding box as JSON: [[south,west],[north,east]] |
opacity |
1.0 |
Overlay opacity |
interactive |
— | Receive mouse/touch events |
cross-origin |
'' |
CORS setting |
z-index |
0 |
Z-index |
class-name |
'' |
CSS class for the SVG element |
<leaflet-layer-group>
Passthrough container. No attributes. Accepts layers, popups, and tooltips as children.
<leaflet-feature-group>
Passthrough container with Leaflet's FeatureGroup (supports getBounds(), style propagation, etc.). No attributes. Accepts layers, popups, and tooltips as children.
<leaflet-geojson>
All path style attributes apply — each feature GeoJSON builds gets them as its style.
| Attribute | Default | Description |
|---|---|---|
data |
'' |
GeoJSON string |
<leaflet-popup>
Content comes from innerHTML, not an attribute. Mutations to innerHTML sync automatically via a MutationObserver.
| Attribute | Default | Description |
|---|---|---|
lat |
0 |
Latitude (omit to auto-attach to the parent layer's position) |
lng |
0 |
Longitude |
max-width |
300 |
Maximum width (px) |
min-width |
50 |
Minimum width (px) |
max-height |
0 |
Maximum height (px; 0 = unlimited) |
auto-pan |
— | Automatically pan the map to keep the popup visible |
close-button |
— | Show a close button |
auto-close |
— | Close the popup when another popup is opened |
<leaflet-tooltip>
Content comes from innerHTML, not an attribute.
| Attribute | Default | Description |
|---|---|---|
lat |
0 |
Latitude (omit to auto-attach to the parent layer's position) |
lng |
0 |
Longitude |
pane |
— | Map pane name |
offset |
— | Pixel offset as JSON: [x, y] |
direction |
'auto' |
Direction: 'right', 'left', 'top', 'bottom', 'center', 'auto' |
permanent |
— | Always visible (no hover required) |
sticky |
— | Follow the mouse |
opacity |
1.0 |
Tooltip opacity |
<leaflet-control-zoom>
| Attribute | Default | Description |
|---|---|---|
position |
'topleft' |
Corner position |
zoom-in-text |
'+' |
Zoom-in button label |
zoom-in-title |
'Zoom in' |
Zoom-in button tooltip |
zoom-out-text |
'-' |
Zoom-out button label |
zoom-out-title |
'Zoom out' |
Zoom-out button tooltip |
<leaflet-control-attribution>
| Attribute | Default | Description |
|---|---|---|
position |
'bottomright' |
Corner position |
prefix |
'' |
Text before the attribution |
<leaflet-control-scale>
| Attribute | Default | Description |
|---|---|---|
position |
'bottomleft' |
Corner position |
max-width |
100 |
Maximum width of the scale (px) |
metric |
— | Show metric scale (m/km) |
imperial |
— | Show imperial scale (mi/ft) |
update-when-idle |
— | Update only when the map stops moving |
<leaflet-control-layers>
The built-in Leaflet layer switcher. Children with type="base" appear as radio buttons; children with type="overlay" (or no type) appear as checkboxes.
<leaflet-map>
<leaflet-control-layers position="topright" collapsed>
<leaflet-tile-layer type="base" name="Streets" active url="..."></leaflet-tile-layer>
<leaflet-tile-layer type="base" name="Satellite" url="..."></leaflet-tile-layer>
<leaflet-marker type="overlay" name="Cities" active lat="51.5" lng="-0.09"></leaflet-marker>
</leaflet-control-layers>
</leaflet-map>
| Attribute | Default | Description |
|---|---|---|
position |
'topright' |
Corner position |
collapsed |
true (toggle with collapsed="false") |
Collapse into an icon until hovered |
auto-z-index |
true |
Assign increasing z-indexes to layers |
hide-single-base |
— | Hide the base layers section when only one base layer exists |
sort-layers |
— | Sort layers alphabetically |
Each child layer inside <leaflet-control-layers> may carry:
| Attribute | Description |
|---|---|
type="base" |
Show as a radio button (base layer). Omit or set type="overlay" for a checkbox. |
name |
Human-readable label displayed in the control. |
active |
Add the layer to the map immediately so it starts visible. Internally, the control dispatches a leaflet-add-layer event that the map handles. Layers without active are removed from the map before the control renders, so they correctly appear unchecked. |
The control intercepts child leaflet-register events and stops propagation — layers inside <leaflet-control-layers> are managed by the layers control, not added directly to the map.
Path style
The following attributes apply to <leaflet-circle>, <leaflet-circle-marker>, <leaflet-polyline>, <leaflet-polygon>, <leaflet-rectangle>, and <leaflet-geojson>.
| Attribute | Default | Description |
|---|---|---|
stroke |
true (toggle with stroke="false") |
Draw the stroke |
color |
'#3388ff' |
Stroke color |
weight |
3 |
Stroke width (px) |
opacity |
1.0 |
Stroke opacity |
line-cap |
'round' |
Line cap: 'butt', 'round', 'square' |
line-join |
'round' |
Line join: 'miter', 'round', 'bevel' |
dash-array |
'' |
Dash pattern, e.g. '5, 10' |
dash-offset |
'' |
Dash offset |
fill |
true (toggle with fill="false") |
Enable fill |
fill-color |
'#3388ff' |
Fill color |
fill-opacity |
0.2 |
Fill opacity |
fill-rule |
'evenodd' |
Fill rule: 'nonzero', 'evenodd' |
interactive |
true (toggle with interactive="false") |
Receive mouse/touch events |
bubbling-mouse-events |
true (toggle with bubbling-mouse-events="false") |
Let mouse events on this layer also reach the map |
class-name |
'' |
CSS class for the path element |
pane |
'overlay' |
Map pane name |
stroke, color, weight, opacity, line-cap, line-join, dash-array, dash-offset, fill, fill-color, fill-opacity, and fill-rule are live — Leaflet has a setter for them (setStyle). interactive, bubbling-mouse-events, class-name, and pane only apply at construction.
Nesting rules
- Popup / tooltip as child of a layer → bound via
bindPopup/bindTooltip. - Layer as child of a group → added via
addLayer. - Any layer as child of
leaflet-map→ added to the map directly. leaflet-polygon/leaflet-polylinetake their coordinates from<leaflet-line>children, not from attributes.<leaflet-icon>/<leaflet-div-icon>as child of<leaflet-marker>→ swapped in viasetIcon().- Layer as child of
<leaflet-control-layers>→ intercepted by the control and registered as a base or overlay entry. The control stops propagation so the layer doesn't reach the map directly. Useactiveto start the layer visible.
Development
npm run build # tsc emits individual ESM modules to dist/ (no bundling)
npm run typecheck # tsc --noEmit, then tsc -p tsconfig.test.json for test/
npm run lint # oxlint over src/ and test/
npm run format # Prettier
npm run test # vitest run
npm run test:watch # vitest, watch mode
# serve the project root with any static-file server and open index.html