# leaflet-components
[Leaflet.js](https://leafletjs.com/) as native Web Components (Custom Elements). Each HTML element maps 1:1 to a Leaflet object with reactive attribute binding.
## Installation
```bash
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.
```js
// 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.
```html
Hello!
Hover me
I am a circle
```
## `leaflet-map`
The root component. All other components must be children of ``.
```html
```
### 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
```js
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:
```js
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 `` 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:` 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.
```js
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 `` see each one twice.
Which events a component can fire depends on the Leaflet class it wraps — see [Leaflet's own event reference](https://leafletjs.com/reference.html) 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:
```ts
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:
```ts
marker.addEventListener('leaflet:dragend', (e) => {
e.detail.distance; // number — DragEndEvent
});
// Type error: baselayerchange is only fired by , not .
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
### ``
| 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.
### ``
All `` 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'` |
### ``
| 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 `` or `` child sets the marker's icon (see below); without one, Leaflet's default icon is used.
### ``
Sets the marker icon to an image. Not rendered itself — swapped into a parent `` 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 |
### ``
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 ``.
| 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]` |
### ``
All [path style](#path-style) attributes apply.
| Attribute | Default | Description |
| --------- | ------- | ---------------- |
| `lat` | `0` | Center latitude |
| `lng` | `0` | Center longitude |
| `radius` | `1000` | Radius in meters |
### ``
All [path style](#path-style) attributes apply.
| Attribute | Default | Description |
| --------- | ------- | ---------------- |
| `lat` | `0` | Center latitude |
| `lng` | `0` | Center longitude |
| `radius` | `10` | Radius in pixels |
### ``
Coordinates are taken from child `` elements, not from attributes. All [path style](#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 |
### ``
Coordinates are taken from child `` elements, not from attributes. All [path style](#path-style) attributes apply.
### ``
All [path style](#path-style) attributes apply.
| Attribute | Default | Description |
| --------- | ------- | --------------------------------------------------- |
| `bounds` | `''` | Bounding box as JSON: `[[south,west],[north,east]]` |
### ``
Vertex helper — not rendered directly. Used as a child of `` or ``.
| Attribute | Default | Description |
| --------- | ------- | ----------- |
| `lat` | `0` | Latitude |
| `lng` | `0` | Longitude |
### ``
| 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 |
### ``
| 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 |
### ``
Content comes from an inline `