web-maps-leaflet · git:20260906.d80c3e7 · 2026-09-06 · sha256 ec73047ed303f777
web-maps-leaflet git:20260906.d80c3e7A
Immutable. This exact content is served forever at /api/v1/blob/ec73047ed303f777.
---
name: web-maps-leaflet
description: Leaflet interactive maps. Use when building a 2D map with tile layers, markers, popups, GeoJSON, layer control, custom controls or marker clustering.
---
# Leaflet Interactive Map Patterns
> **Quick Guide:** Leaflet is a small 2D mapping library: `L.map` initializes against a DOM element,
> `L.tileLayer` supplies the base map, and everything drawn on top — markers, popups, GeoJSON,
> controls — is a layer added to and removed from the map independently. `L.geoJSON` does most of
> the data work through its `pointToLayer`, `onEachFeature`, `style` and `filter` callbacks. Marker
> count is the decision that shapes the rest: past a hundred, DOM markers stop scaling and the work
> moves to clustering or the canvas renderer. **Current: v1.9.4**, with types in `@types/leaflet`.
**Detailed Resources:**
- [examples/core.md](examples/core.md) — map setup, tile providers, markers and icons, GeoJSON, layer groups and control, events
- [examples/advanced.md](examples/advanced.md) — custom controls, clustering, TypeScript, canvas rendering, bounds and viewport
- [reference.md](reference.md) — `L.map` methods, `L.geoJSON` and cluster options, event table, install checklist
---
<critical_requirements>
## Before writing Leaflet code
**Call `map.remove()` when the map goes away.** It is the one call that tears down the resize
observers, animation frames and DOM listeners Leaflet attached; without it a re-mount on the same
element throws "Map container is already initialized".
**Give every tile layer an `attribution`.** OpenStreetMap and most other providers require it in
their terms, and there is no default.
**Move past DOM markers at around a hundred points** — `L.markerClusterGroup`, or `L.circleMarker`
on the canvas renderer. Each `L.marker` is an element in the document, and the page slows in
proportion.
</critical_requirements>
---
**Auto-detection:** Leaflet, `L.map`, `L.tileLayer`, `L.marker`, `L.popup`, `L.geoJSON`, `L.control.layers`, `L.layerGroup`, `L.featureGroup`, `L.icon`, `L.divIcon`, `L.circleMarker`, `L.polyline`, `L.polygon`, `L.Control.extend`, `L.DomUtil`, `L.DomEvent`, markerClusterGroup, leaflet.markercluster, `@types/leaflet`, leaflet.css, addTo(map), bindPopup, bindTooltip, onEachFeature, pointToLayer, invalidateSize, flyTo, fitBounds, latLngBounds
**Applies to:**
- Interactive 2D maps with markers, popups, tooltips and overlays
- GeoJSON points, lines and polygons, styled and filtered from their own properties
- Base-layer switching and overlay toggling through a layer control
- Custom controls built on `L.Control.extend`
- Large marker datasets, through clustering or the canvas renderer
- Map, marker and layer events, and camera movement
**Handled elsewhere:**
- Where the tiles come from — Leaflet renders any XYZ raster endpoint, and choosing a provider and
meeting its terms is a separate decision
- Where the GeoJSON comes from — the layer takes an object, and fetching, caching and paging it are
not the map's concern
- How markers, popups and controls look — the map hands you class names and containers, and the CSS
inside them is settled by whatever owns styling
- 3D terrain, globe projection and GPU-rendered vector tiles — this is a 2D raster library with an
SVG or canvas vector layer over it
---
<philosophy>
**Everything on the map is a layer.** Tiles, markers, GeoJSON, even controls — each is added and
removed independently, which is why toggling a dataset is `map.removeLayer(group)` rather than a
rebuild.
**The core is deliberately small** (~42KB gzipped) and covers the common map. Clustering, heatmaps,
drawing and vector tiles are plugins, and a plugin is how the library expects those needs to be met.
**Methods return `this`**, so setup reads as a chain: `L.marker(pos).addTo(map).bindPopup(html)`.
**Interaction is events.** Maps, markers and layers all emit; `.on()` subscribes and `.off()`
unsubscribes, and `map.off()` with no arguments is part of teardown.
</philosophy>
---
<decision_framework>
### Marker strategy, by count
```
< 100 → L.marker with L.icon or L.divIcon
100 – 10K → L.markerClusterGroup
10K – 50K → L.markerClusterGroup with chunkedLoading, and L.circleMarker rather than L.marker
50K+ → canvas rendering, or pre-tiled vector data
```
### Which layer type
```
One coordinate → L.marker (with an icon) or L.circleMarker (for data viz)
A path → L.polyline
An area → L.polygon, or L.circle for a radius in metres
A GeoJSON dataset → L.geoJSON, which handles every geometry type
A group you need to toggle → L.layerGroup, or L.featureGroup where you need getBounds()/bindPopup()
```
### Which icon
```
The default pin → L.marker() with no icon option
A custom image → L.icon({ iconUrl, iconSize, iconAnchor })
Several image variants → L.Icon.extend({ options }), then construct per variant
HTML or CSS content → L.divIcon({ html, className, iconSize })
A data point, many of → L.circleMarker — a vector shape, not a DOM element
```
</decision_framework>
---
<patterns>
## Core patterns
### Pattern 1: Map initialization and tile layers
Target a DOM element, set the view, add a base layer with its attribution.
```typescript
import L from "leaflet";
import "leaflet/dist/leaflet.css";
const map = L.map("map").setView([51.505, -0.09], 13);
L.tileLayer("https://tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution:
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
```
The CSS import is not optional — without it controls, popups and markers render unpositioned.
Full code: [examples/core.md](examples/core.md)
---
### Pattern 2: Markers, popups and tooltips
```typescript
const marker = L.marker([51.5, -0.09]).addTo(map);
marker.bindPopup("<b>Hello</b><br>I am a popup.");
marker.bindTooltip("Hover text", { direction: "top" });
L.popup().setLatLng([51.513, -0.09]).setContent("Standalone").openOn(map);
```
`openOn(map)` closes whatever popup was open; `addTo(map)` leaves it, which is the difference
between one-at-a-time and several.
Full code: [examples/core.md](examples/core.md)
---
### Pattern 3: GeoJSON layers
Four callbacks cover most of what a dataset needs, and none of them requires touching the data.
```typescript
const geoLayer = L.geoJSON(geojsonData, {
pointToLayer: (feature, latlng) => L.circleMarker(latlng, { radius: 8 }),
onEachFeature: (feature, layer) => layer.bindPopup(feature.properties?.name),
style: (feature) => ({ color: feature?.properties?.color ?? "#3388ff" }),
filter: (feature) => feature?.properties?.visible !== false,
}).addTo(map);
```
`filter` excludes a feature before it is rendered, which is cheaper than rendering and hiding it.
Full code: [examples/core.md](examples/core.md)
---
### Pattern 4: Layer groups and layer control
```typescript
const overlays = { Cities: L.layerGroup([markerA, markerB]), Parks: parkGroup };
L.control.layers({ Street: osm, Satellite: satellite }, overlays).addTo(map);
```
Base layers are radio buttons and overlays are checkboxes. `L.featureGroup` where the group needs
`getBounds()`, `bindPopup()` or `setStyle()`; `L.layerGroup` where it is only a container.
Full code: [examples/core.md](examples/core.md)
---
### Pattern 5: Events and interaction
```typescript
map.on("click", (e: L.LeafletMouseEvent) => {
const { lat, lng } = e.latlng;
});
map.on("moveend", () => map.getBounds()); // load data for the new viewport
const handler = () => {};
map.on("zoomend", handler);
map.off("zoomend", handler); // .off() needs the same reference
```
Full code: [examples/core.md](examples/core.md)
---
### Pattern 6: Custom controls
`L.Control.extend` returns a constructor; `onAdd` builds and returns the container element.
```typescript
const InfoControl = L.Control.extend({
options: { position: "bottomleft" as L.ControlPosition },
onAdd(): HTMLElement {
const container = L.DomUtil.create("div", "info-control");
L.DomEvent.disableClickPropagation(container);
return container;
},
});
new InfoControl().addTo(map);
```
`disableClickPropagation` is what stops a click on the control also being a click on the map, and
`disableScrollPropagation` does the same for a scrollable control.
Full code: [examples/advanced.md](examples/advanced.md)
---
### Pattern 7: Marker clustering
`leaflet.markercluster` replaces one DOM element per point with one per visible cluster.
```typescript
const clusterGroup = L.markerClusterGroup({
maxClusterRadius: 50,
disableClusteringAtZoom: 18,
chunkedLoading: true,
});
clusterGroup.addLayers(markers); // bulk add, not one addLayer per marker
map.addLayer(clusterGroup);
```
`chunkedLoading` keeps a bulk add off the main thread long enough for the UI to stay responsive, and
`disableClusteringAtZoom` hands back individual markers once the user is close enough to want them.
Full code: [examples/advanced.md](examples/advanced.md)
---
### Pattern 8: TypeScript
Types ship separately, in `@types/leaflet` and `@types/leaflet.markercluster`.
```typescript
import L, { type LatLngExpression, type MapOptions } from "leaflet";
const options: MapOptions = { center: [51.505, -0.09], zoom: 13 };
const map = L.map("map", options);
```
Full code: [examples/advanced.md](examples/advanced.md)
---
### Pattern 9: Teardown
```typescript
function destroyMap(map: L.Map): void {
map.off(); // every listener
map.remove(); // the map, its layers, and the DOM Leaflet created
}
```
Skipping this leaves listeners and animation frames alive, and a second `L.map()` call against the
same element throws.
</patterns>
---
<performance>
## Performance
**GeoJSON:** use `filter` rather than rendering and hiding; simplify geometry server-side for
overview zooms; add large datasets in chunks with `addData()`; and where a dataset changes entirely,
`clearLayers()` and re-add rather than restyling feature by feature.
**Vector layers:** `preferCanvas: true` on the map, or a per-layer `L.canvas()` renderer, moves
circles and polylines off SVG — worth it past about a thousand shapes. Canvas-rendered layers take
no CSS styling and no SVG filters, so hover effects have to come from Leaflet events.
**Markers:** `L.circleMarker` is a vector shape rather than a DOM element, so it costs far less than
`L.marker` for a data point that does not need an icon.
**Popups:** set large popup content lazily on the `popupopen` event rather than building it for
every marker up front.
**Containers:** call `map.invalidateSize()` after the container changes size — a CSS transition, an
accordion, a tab switch — or the map keeps rendering to its old dimensions.
</performance>
---
<red_flags>
## Red flags
**Breaks at runtime:**
- No `map.remove()` on teardown — listeners and animation frames survive, and re-initializing on the
same element throws "Map container is already initialized"
- `map.fitBounds` on an empty `FeatureGroup` — throws — check `bounds.isValid()` first
- `leaflet/dist/leaflet.css` not imported — controls, popups and markers render unpositioned, which
looks like a layout bug rather than a missing import
- Default marker icons under a bundler — the CSS-relative image paths no longer resolve and markers
render broken — set them explicitly through `L.Icon.Default.mergeOptions`
- A tile layer with no `attribution` — breaches the terms of OpenStreetMap and most other providers
- `L.marker` for a few hundred points — one DOM element each, and the page degrades steadily —
cluster, or use `L.circleMarker`
**Surprising behaviour:**
- A container that changed size renders grey tiles and mis-targeted clicks until `invalidateSize()`
is called
- `openOn(map)` closes the previously open popup; only `addTo(map)` leaves several open
- Clicks on a custom control also reach the map unless the container went through
`L.DomEvent.disableClickPropagation`
- GeoJSON coordinates are `[longitude, latitude]` while `L.latLng` takes `[latitude, longitude]` —
the commonest coordinate bug, and `L.geoJSON` flips them for you so only hand-built coordinates
are at risk
- `flyTo` and `panTo` cancel each other when called in quick succession
- `L.Control.extend({...})` returns a constructor rather than an instance — Leaflet's own class
system, not ES classes, so it is `new` at the call site
- A tile layer's `maxZoom` limits tile availability and the map's `maxZoom` limits the user; set the
map higher than the tiles and the extra zoom levels are grey
- `refreshClusters()` has to be called after changing a marker's icon or data — clusters do not
notice on their own
</red_flags>