Leaflet Interactive Map Patterns
Quick Guide: Leaflet is a small 2D mapping library:
L.mapinitializes against a DOM element,L.tileLayersupplies the base map, and everything drawn on top — markers, popups, GeoJSON, controls — is a layer added to and removed from the map independently.L.geoJSONdoes most of the data work through itspointToLayer,onEachFeature,styleandfiltercallbacks. 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 — map setup, tile providers, markers and icons, GeoJSON, layer groups and control, events
- examples/advanced.md — custom controls, clustering, TypeScript, canvas rendering, bounds and viewport
- reference.md —
L.mapmethods,L.geoJSONand cluster options, event table, install checklist
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.
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
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.
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
Core patterns
Pattern 1: Map initialization and tile layers
Target a DOM element, set the view, add a base layer with its attribution.
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
Pattern 2: Markers, popups and tooltips
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
Pattern 3: GeoJSON layers
Four callbacks cover most of what a dataset needs, and none of them requires touching the data.
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
Pattern 4: Layer groups and layer control
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
Pattern 5: Events and interaction
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
Pattern 6: Custom controls
L.Control.extend returns a constructor; onAdd builds and returns the container element.
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
Pattern 7: Marker clustering
leaflet.markercluster replaces one DOM element per point with one per visible cluster.
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
Pattern 8: TypeScript
Types ship separately, in @types/leaflet and @types/leaflet.markercluster.
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
Pattern 9: Teardown
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.
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.
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.fitBoundson an emptyFeatureGroup— throws — checkbounds.isValid()firstleaflet/dist/leaflet.cssnot 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.markerfor a few hundred points — one DOM element each, and the page degrades steadily — cluster, or useL.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; onlyaddTo(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]whileL.latLngtakes[latitude, longitude]— the commonest coordinate bug, andL.geoJSONflips them for you so only hand-built coordinates are at risk flyToandpanTocancel each other when called in quick successionL.Control.extend({...})returns a constructor rather than an instance — Leaflet's own class system, not ES classes, so it isnewat the call site- A tile layer's
maxZoomlimits tile availability and the map'smaxZoomlimits 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