You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
leaflet-components/README.md

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 (01)

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-polyline take their coordinates from <leaflet-line> children, not from attributes.
  • <leaflet-icon> / <leaflet-div-icon> as child of <leaflet-marker> → swapped in via setIcon().
  • 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. Use active to 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