npm name `leaflet-components` is taken by another author, so the npm package
is renamed to `leaflet-web-components`; JSR stays `@buddy/leaflet-components`.
Adds the MIT `LICENSE` file (was declared but missing).
Entry split so JSR can publish with no slow types:
- `src/index.ts` is the shared/JSR entry and no longer carries any
`declare global`.
- `src/core/globals.ts` (new) holds the `HTMLElementTagNameMap` /
`HTMLElementEventMap` augmentations; imported only by the new npm entry
`src/index.npm.ts` and by `test/setup.ts`, and listed in `jsr.json`'s
`publish.exclude` so it never enters JSR's module graph.
- `package.json` `.`/`main`/`module`/`types` now point at `dist/index.npm.*`.
Every element file switches from `class extends WithProps({...})` to a
`const PROPS` (with an explicit type) plus
`const Base: LeafletElementConstructor<TheClass, typeof PROPS> = WithProps(PROPS)`,
which is what clears JSR's `unsupported-super-class-expr` /
`missing-explicit-type` errors. Adds a `Positional<T, Obj>` alias in
`props.ts` and explicit object types on the `shared-props.ts` fragments to
keep those annotations short. Empty group tables use `Record<never, never>`.
`npx jsr publish --dry-run` now reports "Success" with zero slow-type
errors; `npm run typecheck`, `lint`, `test` (76) and `build` all pass.
CLAUDE.md, docs/ and README.md updated for the rename, the entry split, and
the `const PROPS` / `const Base` pattern.
|
2 weeks ago | |
|---|---|---|
| demos | 3 months ago | |
| docs | 2 weeks ago | |
| src | 2 weeks ago | |
| test | 2 weeks ago | |
| .gitignore | 4 months ago | |
| CLAUDE.md | 2 weeks ago | |
| LICENSE | 2 weeks ago | |
| README.md | 2 weeks ago | |
| index.html | 3 months ago | |
| jsr.json | 2 weeks ago | |
| oxfmt.config.ts | 3 weeks ago | |
| oxlint.config.ts | 4 weeks ago | |
| package-lock.json | 3 weeks ago | |
| package.json | 2 weeks ago | |
| tsconfig.json | 3 months ago | |
| tsconfig.test.json | 4 weeks ago | |
| vitest.config.ts | 4 weeks ago | |
README.md
leaflet-web-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-web-components leaflet
Also published to JSR as @buddy/leaflet-components (deno add jsr:@buddy/leaflet-components). leaflet is always a peer dependency you install yourself; there is no CommonJS or UMD build. The examples below use the npm name — substitute @buddy/leaflet-components if you pull from JSR.
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-web-components';
// Register only one component (defines the <leaflet-marker> tag)
import 'leaflet-web-components/components/leaflet-marker.js';
Each component is two modules with the same basename: components/leaflet-marker.js is the side-effecting one that calls customElements.define(), and elements/leaflet-marker.js is just the class (default export, no define). Import from elements/ when you want to subclass a component or register it under a different tag name:
// The class only — nothing is registered
import LeafletMarkerElement from 'leaflet-web-components/elements/leaflet-marker.js';
// …or the whole set, by name
import { LeafletMarkerElement, LeafletCircleElement } from 'leaflet-web-components/elements';
On npm the package root resolves to a build that also installs the ambient TypeScript augmentations (HTMLElementTagNameMap so document.querySelector('leaflet-map') is typed, and the custom-event map). The JSR build omits those — JSR's type rules disallow global augmentation — so from JSR you add your own if you want them.
Usage
Import once to register all custom elements, then use them declaratively in HTML.
<script type="module">
import 'leaflet-web-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' |
For a CRS Leaflet doesn't ship (a custom projection from a plugin, say), nest a CRS-providing element instead of using the crs attribute. It's just a custom element that fires a leaflet-crs-changed event on itself, bubbling, carrying the actual L.CRS value — no base class required:
class MyCRSProvider extends HTMLElement {
connectedCallback() {
this.dispatchEvent(
new CustomEvent('leaflet-crs-changed', { bubbles: true, detail: { crs: myPluginCRS } }),
);
}
}
customElements.define('my-crs-provider', MyCRSProvider);
<leaflet-tile-layer-wms url="..." layers="...">
<my-crs-provider></my-crs-provider>
</leaflet-tile-layer-wms>
A nested provider takes priority over the crs attribute when both are present. crs is construction-only (Leaflet has no live setter for it), so picking one up — from either source — rebuilds the whole layer; place the provider so it connects before you need the correct projection to take effect, since there's nothing to revert to partway through a request.
<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.
Building your own components
This library's own components are built from a small toolkit — a mixin, some prop-codec builders, and a few reusable fragments — and that whole toolkit is exported from the package root. A component wrapping a Leaflet plugin (a custom layer, control, or icon) is built exactly the same way <leaflet-marker> or <leaflet-circle> are, whether or not it ever ends up in this package.
The pattern
- Describe your attributes as a
const PROPStable, build a base class withconst Base = WithProps(PROPS, options?), thenextends Base. - Implement
createLeafletObject(options), returning whatever Leaflet object your plugin provides. declare readonly leafletObject?: TheType;—WithProps's own inference of the object type from thePROPStable alone isn't reliable enough to skip this (every component in this package does it).- Call
customElements.define('my-plugin-layer', MyPluginLayer). (If you want to let your consumers subclass or re-register, mirror this package's split: put the class in its own module as adefaultexport and keep thedefine()call in a separate side-effecting module.)
import { MyClusterGroup, type MyClusterGroupOptions } from 'some-leaflet-plugin';
import {
WithProps,
bool,
num,
type PropDef,
type LeafletElementConstructor,
} from 'leaflet-web-components';
const PROPS: {
radius: PropDef<number>;
disableClusteringAtZoom: PropDef<number>;
spiderfy: PropDef<boolean>;
} = {
radius: num(80),
disableClusteringAtZoom: num(18),
spiderfy: bool(true),
};
const Base: LeafletElementConstructor<MyClusterGroup, typeof PROPS> = WithProps(PROPS);
class MyClusterLayer extends Base {
declare readonly leafletObject?: MyClusterGroup;
createLeafletObject(options: MyClusterGroupOptions): MyClusterGroup {
return new MyClusterGroup(options);
}
}
customElements.define('my-cluster-layer', MyClusterLayer);
The two consts with explicit type annotations — rather than
class X extends WithProps({ ... }) inline — are what lets this package
publish to JSR, whose type checker rejects a call expression as a superclass
and any public type it can't resolve without full inference. If you're only
consuming the package in an app (not publishing your component to JSR), the
inline extends WithProps({ ... }) form works too.
That's it — no other integration point is needed. WithProps handles observedAttributes, attribute↔property sync, building the options object from attributes on connect, dispatching attribute changes to the matching Leaflet setter (or a set you provide per prop, for anything a setter can't handle), and re-emitting every event your object fires as leaflet:<type>.
Attribute builders (PROPS table entries)
| Builder | Attribute holds | Notes |
|---|---|---|
str(default?, opts?) |
a string | |
num(default?, opts?) |
a number | |
bool(default?, opts?) |
a boolean | present/absent by default; use bool(true, ...) for an option Leaflet defaults to true, so attr="false" can opt out |
choice<'a' | 'b'>(default, opts?) |
a string literal union | not runtime-validated, just typed |
json<T>(default, opts?) |
JSON | decodes/encodes via JSON.parse/JSON.stringify — for points, bounds, arbitrary objects |
disabled(opts?) |
a boolean | the inverse of bool(true): attribute is auto-named disable-<kebab-name>, presence means false |
positional(def) |
— | wraps any of the above; marks a prop your Leaflet constructor takes as an argument rather than an option, so it's excluded from the options object createLeafletObject receives |
Each builder's opts can include set(obj, value, el) (called on attribute change instead of the default setXyz() lookup — needed when there's no matching setter, or the update needs more than one value, like a lat/lng pair), get(obj) (backs the live property getter, and — paired with event — keeps the attribute in sync whenever your object fires that event), and attribute (override the default kebab-cased name).
Reusing the built-in fragments
Common attribute groups are already factored out and exported: pathProps (Leaflet Path styling — color, weight, dashes, fill, etc.), latLngProps (a synced lat/lng pair, for anything positioned on the map), tileLayerProps (the base GridLayer/TileLayer options), and urlProp. Each is exported with an explicit object type, so a PROPS annotation can pull it in with typeof. A custom vector layer plugin, for instance, can just spread pathProps in instead of redeclaring styling attributes:
import {
WithProps,
pathProps,
positional,
json,
type Positional,
type LeafletElementConstructor,
} from 'leaflet-web-components';
const PROPS: typeof pathProps & {
data: Positional<MyData | null>;
} = {
...pathProps,
data: positional(json<MyData | null>(null)),
};
const Base: LeafletElementConstructor<MyShape, typeof PROPS> = WithProps(PROPS);
class MyShapeLayer extends Base {
/* ... */
}
Positional<T, Obj> is the exported alias for a positional() prop's type
(PropDef<T, Obj> & { option: false }).
Nesting into the tree
By default (attach: 'children', the default when you omit options), your component registers itself with its nearest ancestor and accepts registering descendants as child layers/popups/tooltips. Use attach: 'self' for something that has a parent but manages no children of its own (a popup, a tooltip, a control); use attach: 'none' for something that's neither (an icon).
The registration check on the receiving side keys off instanceof Layer / instanceof Popup / instanceof Tooltip — Leaflet's own base classes, not this package's. Since real Leaflet plugins are conventionally built by extending those same classes, a plugin layer or control nests correctly under <leaflet-map>, <leaflet-layer-group>, <leaflet-feature-group>, or <leaflet-control-layers> with no special-casing on either side.
Typing events
LeafletAddEventListener<TEvents>/LeafletRemoveEventListener<TEvents> narrow addEventListener/removeEventListener for leaflet:<name> events, the same way every component in this package does:
import {
WithProps,
type LeafletAddEventListener,
type LeafletRemoveEventListener,
type PathEvents,
} from 'leaflet-web-components';
// Base = WithProps(PROPS), as in the previous example
class MyShapeLayer extends Base {
declare addEventListener: LeafletAddEventListener<PathEvents>;
declare removeEventListener: LeafletRemoveEventListener<PathEvents>;
/* ... */
}
event-types.ts's fragments (MouseEvents, PopupBindEvents, DragEvents, TileEvents, ...) and their compositions (PathEvents, MarkerEvents, TileLayerEvents, GroupEvents, MapEvents, ...) are all exported for reuse; compose your own if your plugin fires events none of them cover. If you also want document.createElement('my-cluster-layer') to infer your class, augment HTMLElementTagNameMap:
declare global {
interface HTMLElementTagNameMap {
'my-cluster-layer': MyClusterLayer;
}
}
This package keeps its own version of that augmentation in a dedicated module (src/core/globals.ts) that its npm entry imports and its JSR entry doesn't — JSR disallows declare global. If you publish your plugin to JSR, do the same: keep the augmentation out of your JSR entry's module graph.
Custom marker icons
<leaflet-marker> listens for a generic icon-changed event and calls setIcon() with whatever detail.icon holds — it doesn't check which component fired it. A component wrapping a plugin that provides its own icon (e.g. a themed marker icon set) just needs to call emitIconChanged:
import { emitIconChanged } from 'leaflet-web-components';
// after building or updating your icon:
emitIconChanged(this, myPluginIcon);
// and on disconnect:
emitIconChanged(this, null);
and it plugs into any <leaflet-marker> immediately, the same as this package's own <leaflet-icon>/<leaflet-div-icon>.
One ordering pitfall, if your plugin has multiple custom elements
customElements.define() upgrades every matching element already parsed into the page immediately and synchronously. If one of your custom elements listens for a bubbling announcement fired by another of your own custom elements on connect (the way <leaflet-polygon> listens for <leaflet-line>), the listening one must be registered (customElements.defined) first — otherwise, on a real static-HTML page, every instance of the "announcer" tag upgrades and fires its one-shot announcement before the "listener" tag even exists, and it's lost for good. This only matters for relationships within your own plugin; it doesn't affect nesting into this package's components, which is handled by the bubbling registration protocol above, not a one-shot announcement.
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 # oxfmt
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
Design docs — how the library is built and why — are in docs/.