| name | leaflet-cookbook |
| description | Comprehensive Leaflet patterns for custom markers, styled popups, controls, GeoJSON, clustering, and dark mode. Use when searching for Leaflet styling, marker customization, popup design, or interactive map features. |
Leaflet Cookbook
A comprehensive reference library of Leaflet patterns for creating custom markers, styled popups, interactive controls, GeoJSON visualizations, marker clustering, and theme switching.
Table of Contents
- Custom Marker Icons
- Styled Popups and Tooltips
- Custom Controls
- GeoJSON Styling
- Marker Clustering
- Dark Mode Implementation
- Performance Optimization
- Accessibility Patterns
Custom Marker Icons
SVG-Based Custom Markers
Use Case: Create scalable, colorful markers that match your brand
Pattern:
import L from 'leaflet';
interface MarkerSize {
width: number;
height: number;
}
function createColoredIcon(
color: string,
size: MarkerSize = { width: 25, height: 41 }
): L.DivIcon {
const svgIcon = `
<svg width="${size.width}" height="${size.height}"
viewBox="0 0 25 41"
xmlns="http://www.w3.org/2000/svg">
<defs>
<filter id="shadow" x="-50%" y="-50%" width="200%" height="200%">
<feDropShadow dx="0" dy="2" stdDeviation="2" flood-opacity="0.3"/>
</filter>
</defs>
<path
fill="${color}"
stroke="#fff"
stroke-width="2"
filter="url(#shadow)"
d="M12.5 0C5.6 0 0 5.6 0 12.5c0 9.4 12.5 28.5 12.5 28.5S25 21.9 25 12.5C25 5.6 19.4 0 12.5 0z"
/>
<circle cx="12.5" cy="12.5" r="6" fill="#fff" opacity="0.9"/>
</svg>
`;
return L.divIcon({
html: svgIcon,
className: 'custom-svg-icon',
iconSize: [size.width, size.height],
iconAnchor: [size.width / 2, size.height],
popupAnchor: [1, -(size.height - 7)]
});
}
Image-Based Marker Icons
Pattern:
interface IconConfig {
iconUrl: string;
iconRetinaUrl?: string;
shadowUrl?: string;
size: [number, number];
shadowSize?: [number, number];
}
function createImageIcon(config: IconConfig): L.Icon {
const { iconUrl, iconRetinaUrl, shadowUrl, size, shadowSize } = config;
return L.icon({
iconUrl,
iconRetinaUrl: iconRetinaUrl || iconUrl.replace('.png', '-2x.png'),
shadowUrl: shadowUrl || '/markers/marker-shadow.png',
iconSize: size,
iconAnchor: [size[0] / 2, size[1]],
popupAnchor: [1, -(size[1] - 7)],
tooltipAnchor: [size[0] / 2, -(size[1] / 2)],
shadowSize: shadowSize || [size[0] * 1.5, size[1]]
});
}
CSS for SVG Icons:
.custom-svg-icon {
background: none !important;
border: none !important;
transition: transform 0.2s ease;
}
.custom-svg-icon:hover {
transform: scale(1.1);
z-index: 1000 !important;
}
.custom-svg-icon.active {
animation: markerPulse 2s ease-in-out infinite;
}
@keyframes markerPulse {
0%, 100% {
transform: scale(1);
}
50% {
transform: scale(1.15);
}
}
Icon Factory with Categories
Pattern:
type MarkerCategory =
| 'restaurant'
| 'hotel'
| 'attraction'
| 'shopping'
| 'default';
interface CategoryConfig {
color: string;
iconClass: string;
label?: string;
}
const categoryConfigs: Record<MarkerCategory, CategoryConfig> = {
restaurant: {
color: '#ef4444',
iconClass: 'icon-restaurant',
label: 'R'
},
hotel: {
color: '#3b82f6',
iconClass: 'icon-hotel',
label: 'H'
},
attraction: {
color: '#10b981',
iconClass: 'icon-attraction',
label: 'A'
},
shopping: {
color: '#f59e0b',
iconClass: 'icon-shopping',
label: 'S'
},
default: {
color: '#6b7280',
iconClass: 'icon-default',
label: '•'
}
};
function getIconByCategory(category: MarkerCategory = 'default'): L.DivIcon {
const config = categoryConfigs[category];
const html = `
<div class="marker-pin" style="background-color: ${config.color}">
<div class="marker-label">${config.label}</div>
</div>
<div class="marker-shadow"></div>
`;
return L.divIcon({
html,
className: `custom-category-icon ${config.iconClass}`,
iconSize: [30, 42],
iconAnchor: [15, 42],
popupAnchor: [0, -42]
});
}
CSS for Category Icons:
.custom-category-icon {
background: none !important;
border: none !important;
position: relative;
}
.marker-pin {
width: 30px;
height: 38px;
border-radius: 50% 50% 50% 0;
background: var(--color-primary);
position: absolute;
transform: rotate(-45deg);
left: 50%;
top: 50%;
margin: -20px 0 0 -15px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 3px 6px rgba(0, 0, 0, 0.3);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.marker-pin:hover {
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
transform: rotate(-45deg) scale(1.1);
}
.marker-label {
transform: rotate(45deg);
color: white;
font-weight: 700;
font-size: 14px;
line-height: 1;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.marker-shadow {
width: 30px;
height: 10px;
background: radial-gradient(
ellipse at center,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0) 70%
);
position: absolute;
bottom: -5px;
left: 50%;
transform: translateX(-50%);
}
Styled Popups and Tooltips
Rich Media Popup Pattern
Use Case: Display detailed information with images and actions
Pattern:
interface PopupMetadata {
icon: string;
label: string;
value: string;
}
interface PopupAction {
label: string;
className: string;
onClick?: () => void;
}
interface PopupConfig {
title: string;
image?: string;
badge?: string;
description: string;
metadata?: PopupMetadata[];
actions?: PopupAction[];
}
function createRichPopup(config: PopupConfig): L.Popup {
const {
title,
image,
badge,
description,
metadata = [],
actions = []
} = config;
const content = `
<div class="leaflet-rich-popup">
${badge ? `
<div class="popup-badge">${badge}</div>
` : ''}
${image ? `
<div class="popup-image">
<img src="${image}" alt="${title}" loading="lazy" />
</div>
` : ''}
<div class="popup-header">
<h3>${title}</h3>
</div>
<div class="popup-body">
<p class="popup-description">${description}</p>
${metadata.length > 0 ? `
<div class="popup-meta">
${metadata.map(item => `
<div class="meta-item">
<span class="meta-icon" role="img" aria-label="${item.label}">
${item.icon}
</span>
<div class="meta-content">
<span class="meta-label">${item.label}</span>
<span class="meta-value">${item.value}</span>
</div>
</div>
`).join('')}
</div>
` : ''}
</div>
${actions.length > 0 ? `
<div class="popup-footer">
${actions.map((action, i) => `
<button
class="popup-btn ${action.className}"
data-action="${i}"
type="button"
>
${action.label}
</button>
`).join('')}
</div>
` : ''}
</div>
`;
const popup = L.popup({
maxWidth: 320,
minWidth: 280,
className: 'custom-popup',
closeButton: true,
autoClose: false,
closeOnClick: false
}).setContent(content);
popup.on('add', () => {
const popupElement = popup.getElement();
if (popupElement && actions.length > 0) {
actions.forEach((action, i) => {
const btn = popupElement.querySelector(`[data-action="${i}"]`);
if (btn && action.onClick) {
btn.addEventListener('click', action.onClick);
}
});
}
});
return popup;
}
CSS for Rich Popups:
.custom-popup .leaflet-popup-content-wrapper {
background: var(--color-surface, white);
border-radius: var(--radius-lg, 12px);
box-shadow:
0 10px 25px rgba(0, 0, 0, 0.15),
0 4px 6px rgba(0, 0, 0, 0.1);
padding: 0;
overflow: hidden;
}
.custom-popup .leaflet-popup-content {
margin: 0;
line-height: 1.5;
font-size: 14px;
color: var(--color-text-primary, #1f2937);
}
.custom-popup .leaflet-popup-tip {
background: var(--color-surface, white);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15);
}
.custom-popup .leaflet-popup-close-button {
color: var(--color-text-secondary, #6b7280);
font-size: 24px;
padding: 8px 12px;
transition: color 0.2s;
}
.custom-popup .leaflet-popup-close-button:hover {
color: var(--color-text-primary, #1f2937);
}
.popup-badge {
position: absolute;
top: 12px;
right: 12px;
background: var(--color-success, #10b981);
color: white;
padding: 4px 12px;
border-radius: var(--radius-full, 20px);
font-size: 12px;
font-weight: 600;
z-index: 10;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.popup-image {
width: 100%;
height: 160px;
overflow: hidden;
background: var(--color-gray-100, #f3f4f6);
}
.popup-image img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.popup-header {
padding: 16px 20px;
border-bottom: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface, white);
}
.popup-header h3 {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary, #1f2937);
line-height: 1.4;
}
.popup-body {
padding: 16px 20px;
}
.popup-description {
margin: 0 0 16px;
line-height: 1.6;
color: var(--color-text-secondary, #4b5563);
}
.popup-meta {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 16px;
padding-top: 16px;
border-top: 1px solid var(--color-border, #e5e7eb);
}
.meta-item {
display: flex;
align-items: center;
gap: 12px;
}
.meta-icon {
font-size: 20px;
line-height: 1;
flex-shrink: 0;
}
.meta-content {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
.meta-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--color-text-tertiary, #9ca3af);
}
.meta-value {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary, #1f2937);
}
.popup-footer {
padding: 12px 16px;
background: var(--color-gray-50, #f9fafb);
border-top: 1px solid var(--color-border, #e5e7eb);
display: flex;
gap: 8px;
}
.popup-btn {
flex: 1;
padding: 10px 16px;
border: none;
border-radius: var(--radius-md, 6px);
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
text-align: center;
}
.popup-btn.btn-primary {
background: var(--color-primary, #3b82f6);
color: white;
}
.popup-btn.btn-primary:hover {
background: var(--color-primary-dark, #2563eb);
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(59, 130, 246, 0.3);
}
.popup-btn.btn-secondary {
background: transparent;
color: var(--color-primary, #3b82f6);
border: 1px solid var(--color-primary, #3b82f6);
}
.popup-btn.btn-secondary:hover {
background: var(--color-primary-light, #eff6ff);
}
Styled Tooltip Pattern
Pattern:
interface TooltipOptions {
direction?: 'top' | 'bottom' | 'left' | 'right' | 'center' | 'auto';
permanent?: boolean;
sticky?: boolean;
opacity?: number;
offset?: [number, number];
}
function createStyledTooltip(
content: string,
options: TooltipOptions = {}
): L.Tooltip {
const {
direction = 'top',
permanent = false,
sticky = false,
opacity = 0.95,
offset = [0, -10]
} = options;
return L.tooltip({
permanent,
direction,
sticky,
opacity,
offset,
className: 'custom-tooltip'
}).setContent(content);
}
CSS for Tooltips:
.custom-tooltip {
background: rgba(0, 0, 0, 0.9);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md, 8px);
color: white;
font-size: 13px;
padding: 8px 14px;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.3),
0 0 0 1px rgba(255, 255, 255, 0.05);
font-weight: 500;
line-height: 1.5;
}
.custom-tooltip::before {
border-top-color: rgba(0, 0, 0, 0.9);
}
.custom-tooltip strong {
font-weight: 700;
color: var(--color-primary-light, #93c5fd);
}
.dark-mode .custom-tooltip {
background: rgba(255, 255, 255, 0.95);
color: var(--color-text-primary, #1f2937);
border-color: rgba(0, 0, 0, 0.1);
}
.dark-mode .custom-tooltip::before {
border-top-color: rgba(255, 255, 255, 0.95);
}
Custom Controls
Multi-Function Custom Control
Use Case: Create custom map controls with multiple features
Pattern:
interface ControlAction {
id: string;
icon: string;
label: string;
onClick: (map: L.Map) => void;
}
L.Control.CustomControl = L.Control.extend({
options: {
position: 'topleft' as L.ControlPosition,
title: 'Map Controls',
actions: [] as ControlAction[]
},
onAdd: function(map: L.Map) {
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control leaflet-custom-control');
const header = L.DomUtil.create('div', 'control-header', container);
header.innerHTML = `<h4>${this.options.title}</h4>`;
const body = L.DomUtil.create('div', 'control-body', container);
const locateBtn = this.createButton(
'📍',
'My Location',
'locate',
() => {
map.locate({ setView: true, maxZoom: 16 });
}
);
body.appendChild(locateBtn);
const searchBtn = this.createButton(
'🔍',
'Search',
'search',
() => {
console.log('Search clicked');
}
);
body.appendChild(searchBtn);
const filterBtn = this.createButton(
'⚙️',
'Filter',
'filter',
() => {
console.log('Filter clicked');
}
);
body.appendChild(filterBtn);
const divider = L.DomUtil.create('div', 'control-divider', body);
const toggleContainer = L.DomUtil.create('label', 'control-toggle', body);
toggleContainer.innerHTML = `
<input type="checkbox" id="darkModeToggle">
<span class="toggle-slider"></span>
<span class="toggle-label">Dark Mode</span>
`;
const toggle = toggleContainer.querySelector('#darkModeToggle') as HTMLInputElement;
L.DomEvent.on(toggle, 'change', () => {
document.body.classList.toggle('dark-mode', toggle.checked);
this.onThemeChange?.(toggle.checked);
});
L.DomEvent.disableClickPropagation(container);
L.DomEvent.disableScrollPropagation(container);
return container;
},
createButton: function(
icon: string,
label: string,
action: string,
onClick: () => void
): HTMLElement {
const button = L.DomUtil.create('button', 'control-btn');
button.setAttribute('type', 'button');
button.setAttribute('data-action', action);
button.setAttribute('aria-label', label);
button.innerHTML = `
<span class="btn-icon" role="img">${icon}</span>
<span class="btn-label">${label}</span>
`;
L.DomEvent.on(button, 'click', onClick);
return button;
},
onThemeChange: function(isDark: boolean) {
},
onRemove: function(map: L.Map) {
}
});
function createCustomControl(map: L.Map): L.Control {
const control = new (L.Control.CustomControl as any)({
position: 'topleft',
title: 'Map Controls'
});
return control;
}
CSS for Custom Controls:
.leaflet-custom-control {
background: var(--color-surface, white);
border-radius: var(--radius-lg, 12px);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 4px rgba(0, 0, 0, 0.1);
overflow: hidden;
min-width: 220px;
border: none;
}
.control-header {
background: linear-gradient(
135deg,
var(--color-primary, #3b82f6) 0%,
var(--color-primary-dark, #2563eb) 100%
);
color: white;
padding: 14px 16px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.control-header h4 {
margin: 0;
font-size: 14px;
font-weight: 700;
letter-spacing: 0.3px;
}
.control-body {
padding: 8px;
background: var(--color-surface, white);
}
.control-btn {
width: 100%;
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
border: none;
background: transparent;
border-radius: var(--radius-md, 8px);
cursor: pointer;
transition: all 0.2s ease;
font-size: 14px;
color: var(--color-text-primary, #1f2937);
text-align: left;
}
.control-btn:hover {
background: var(--color-gray-100, #f3f4f6);
transform: translateX(2px);
}
.control-btn:active {
transform: translateX(2px) scale(0.98);
}
.btn-icon {
font-size: 18px;
line-height: 1;
flex-shrink: 0;
}
.btn-label {
font-weight: 500;
}
.control-divider {
height: 1px;
background: var(--color-border, #e5e7eb);
margin: 8px 4px;
}
.control-toggle {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
cursor: pointer;
transition: background 0.2s;
border-radius: var(--radius-md, 8px);
}
.control-toggle:hover {
background: var(--color-gray-50, #f9fafb);
}
.control-toggle input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
width: 42px;
height: 22px;
background: var(--color-gray-300, #d1d5db);
border-radius: 11px;
position: relative;
transition: background 0.3s;
flex-shrink: 0;
}
.toggle-slider::before {
content: '';
position: absolute;
width: 18px;
height: 18px;
border-radius: 50%;
background: white;
top: 2px;
left: 2px;
transition: transform 0.3s;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.control-toggle input[type="checkbox"]:checked + .toggle-slider {
background: var(--color-primary, #3b82f6);
}
.control-toggle input[type="checkbox"]:checked + .toggle-slider::before {
transform: translateX(20px);
}
.toggle-label {
font-size: 14px;
font-weight: 500;
color: var(--color-text-primary, #1f2937);
}
.leaflet-control-zoom {
border: none;
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.15),
0 2px 4px rgba(0, 0, 0, 0.1);
border-radius: var(--radius-md, 8px);
overflow: hidden;
}
.leaflet-control-zoom a {
width: 36px;
height: 36px;
line-height: 36px;
font-size: 20px;
color: var(--color-text-primary, #374151);
border-bottom: 1px solid var(--color-border, #e5e7eb);
transition: all 0.2s;
background: var(--color-surface, white);
}
.leaflet-control-zoom a:hover {
background: var(--color-gray-50, #f9fafb);
color: var(--color-primary, #3b82f6);
}
.leaflet-control-zoom a:last-child {
border-bottom: none;
}
.dark-mode .leaflet-custom-control {
background: var(--color-dark-surface, #1f2937);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.dark-mode .control-body {
background: var(--color-dark-surface, #1f2937);
}
.dark-mode .control-btn {
color: var(--color-dark-text, #f3f4f6);
}
.dark-mode .control-btn:hover {
background: rgba(255, 255, 255, 0.05);
}
.dark-mode .control-divider {
background: rgba(255, 255, 255, 0.1);
}
.dark-mode .toggle-label {
color: var(--color-dark-text, #f3f4f6);
}
GeoJSON Styling
Data-Driven Choropleth Map
Use Case: Visualize regional data with color-coded polygons
Pattern:
interface FeatureProperties {
name: string;
population?: number;
density?: number;
category?: string;
[key: string]: any;
}
function getChoroplethColor(value: number): string {
return value > 1000 ? '#800026' :
value > 500 ? '#bd0026' :
value > 200 ? '#e31a1c' :
value > 100 ? '#fc4e2a' :
value > 50 ? '#fd8d3c' :
value > 20 ? '#feb24c' :
value > 10 ? '#fed976' :
'#ffeda0';
}
function styleFeature(feature: any): L.PathOptions {
const props = feature.properties as FeatureProperties;
return {
fillColor: getChoroplethColor(props.density || 0),
weight: 2,
opacity: 1,
color: 'white',
dashArray: '3',
fillOpacity: 0.7
};
}
function createChoroplethMap(
map: L.Map,
geojsonData: GeoJSON.FeatureCollection,
propertyName: string
): L.GeoJSON {
const layer = L.geoJSON(geojsonData, {
style: styleFeature,
onEachFeature: (feature, layer) => {
const props = feature.properties as FeatureProperties;
const popupContent = `
<div class="geojson-popup">
<h3>${props.name}</h3>
<div class="popup-stats">
<div class="stat-item">
<span class="stat-label">Population:</span>
<span class="stat-value">${props.population?.toLocaleString() || 'N/A'}</span>
</div>
<div class="stat-item">
<span class="stat-label">Density:</span>
<span class="stat-value">${props.density?.toFixed(2) || 'N/A'}</span>
</div>
</div>
</div>
`;
layer.bindPopup(popupContent, {
className: 'custom-geojson-popup'
});
layer.on({
mouseover: (e) => {
const target = e.target;
target.setStyle({
weight: 4,
color: '#666',
dashArray: '',
fillOpacity: 0.9
});
target.bringToFront();
},
mouseout: (e) => {
layer.resetStyle(e.target);
},
click: (e) => {
map.fitBounds(e.target.getBounds(), {
padding: [50, 50]
});
}
});
}
});
return layer.addTo(map);
}
CSS for GeoJSON Popups:
.custom-geojson-popup .leaflet-popup-content-wrapper {
background: var(--color-surface, white);
border-radius: var(--radius-lg, 12px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
padding: 0;
}
.geojson-popup h3 {
margin: 0;
padding: 16px 20px;
font-size: 18px;
font-weight: 700;
color: var(--color-text-primary, #1f2937);
border-bottom: 2px solid var(--color-primary, #3b82f6);
}
.popup-stats {
padding: 16px 20px;
}
.stat-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
border-bottom: 1px solid var(--color-border, #e5e7eb);
}
.stat-item:last-child {
border-bottom: none;
}
.stat-label {
font-size: 13px;
color: var(--color-text-secondary, #6b7280);
font-weight: 500;
}
.stat-value {
font-size: 15px;
font-weight: 700;
color: var(--color-primary, #3b82f6);
}
Point-to-Layer with Custom Markers
Pattern:
function createPointLayer(
map: L.Map,
pointData: GeoJSON.FeatureCollection
): L.GeoJSON {
return L.geoJSON(pointData, {
pointToLayer: (feature, latlng) => {
const props = feature.properties as FeatureProperties;
return L.circleMarker(latlng, {
radius: props.size || 8,
fillColor: props.category === 'A' ? '#3b82f6' :
props.category === 'B' ? '#10b981' :
props.category === 'C' ? '#f59e0b' : '#ef4444',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 0.8
});
},
onEachFeature: (feature, layer) => {
const props = feature.properties as FeatureProperties;
layer.bindPopup(`
<div class="point-popup">
<strong>${props.name}</strong>
<p>Category: ${props.category}</p>
</div>
`);
layer.bindTooltip(props.name, {
permanent: false,
direction: 'top',
className: 'custom-tooltip'
});
}
}).addTo(map);
}
Marker Clustering
Advanced Marker Clustering
Use Case: Handle thousands of markers efficiently with clustering
Pattern:
import L from 'leaflet';
import 'leaflet.markercluster';
interface Location {
id: string;
lat: number;
lng: number;
name: string;
category: string;
color: string;
}
function createClusterIcon(cluster: L.MarkerCluster): L.DivIcon {
const childCount = cluster.getChildCount();
let sizeClass = 'marker-cluster-';
if (childCount < 10) {
sizeClass += 'small';
} else if (childCount < 100) {
sizeClass += 'medium';
} else {
sizeClass += 'large';
}
return L.divIcon({
html: `
<div class="cluster-inner">
<div class="cluster-count">${childCount}</div>
<div class="cluster-ring"></div>
</div>
`,
className: `marker-cluster ${sizeClass}`,
iconSize: L.point(50, 50)
});
}
function createMarkerClusters(
map: L.Map,
locations: Location[]
): L.MarkerClusterGroup {
const clusters = L.markerClusterGroup({
iconCreateFunction: createClusterIcon,
spiderfyOnMaxZoom: true,
showCoverageOnHover: true,
zoomToBoundsOnClick: true,
maxClusterRadius: 80,
disableClusteringAtZoom: 16,
spiderfyDistanceMultiplier: 2,
chunkedLoading: true,
chunkInterval: 200,
chunkDelay: 50
});
locations.forEach(location => {
const icon = createColoredIcon(location.color);
const marker = L.marker([location.lat, location.lng], {
icon,
title: location.name
});
const popup = createRichPopup({
title: location.name,
description: `Location in ${location.category} category`,
metadata: [
{ icon: '📍', label: 'Category', value: location.category },
{ icon: '🆔', label: 'ID', value: location.id }
]
});
marker.bindPopup(popup);
clusters.addLayer(marker);
});
return clusters;
}
CSS for Marker Clusters:
.marker-cluster {
background-clip: padding-box;
border-radius: 50%;
background: transparent;
}
.cluster-inner {
width: 50px;
height: 50px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.cluster-count {
color: white;
font-size: 15px;
font-weight: 700;
z-index: 2;
position: relative;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
}
.cluster-ring {
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
z-index: 1;
}
.marker-cluster-small .cluster-ring {
background: radial-gradient(
circle at center,
rgba(59, 130, 246, 0.9) 0%,
rgba(59, 130, 246, 0.7) 60%,
rgba(59, 130, 246, 0.3) 100%
);
box-shadow:
0 0 0 8px rgba(59, 130, 246, 0.2),
0 4px 12px rgba(0, 0, 0, 0.3);
}
.marker-cluster-medium .cluster-ring {
background: radial-gradient(
circle at center,
rgba(251, 146, 60, 0.9) 0%,
rgba(251, 146, 60, 0.7) 60%,
rgba(251, 146, 60, 0.3) 100%
);
box-shadow:
0 0 0 8px rgba(251, 146, 60, 0.2),
0 4px 12px rgba(0, 0, 0, 0.3);
}
.marker-cluster-large .cluster-ring {
background: radial-gradient(
circle at center,
rgba(239, 68, 68, 0.9) 0%,
rgba(239, 68, 68, 0.7) 60%,
rgba(239, 68, 68, 0.3) 100%
);
box-shadow:
0 0 0 8px rgba(239, 68, 68, 0.2),
0 4px 12px rgba(0, 0, 0, 0.3);
}
.marker-cluster:hover .cluster-inner {
transform: scale(1.15);
transition: transform 0.2s ease;
}
.marker-cluster-spiderfy {
animation: spiderfyAnimation 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes spiderfyAnimation {
from {
opacity: 0;
transform: scale(0) rotate(0deg);
}
to {
opacity: 1;
transform: scale(1) rotate(180deg);
}
}
.leaflet-cluster-anim .leaflet-marker-icon,
.leaflet-cluster-anim .leaflet-marker-shadow {
transition:
transform 0.3s ease-out,
opacity 0.3s ease-in;
}
.marker-cluster-spider-leg {
stroke: var(--color-primary, #3b82f6);
stroke-width: 2;
stroke-opacity: 0.5;
fill: none;
}
Dark Mode Implementation
Complete Dark Mode System
Use Case: Switch between light and dark map themes
Pattern:
interface MapTheme {
name: string;
tileUrl: string;
attribution: string;
options?: L.TileLayerOptions;
}
const mapThemes: Record<'light' | 'dark' | 'satellite', MapTheme> = {
light: {
name: 'Light',
tileUrl: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© <a href="https://openstreetmap.org">OpenStreetMap</a>',
options: {
maxZoom: 19,
className: 'light-tiles'
}
},
dark: {
name: 'Dark',
tileUrl: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution: '© <a href="https://carto.com">CartoDB</a>',
options: {
maxZoom: 19,
className: 'dark-tiles'
}
},
satellite: {
name: 'Satellite',
tileUrl: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
attribution: '© Esri',
options: {
maxZoom: 19,
className: 'satellite-tiles'
}
}
};
interface ThemeManager {
currentTheme: keyof typeof mapThemes;
setTheme: (theme: keyof typeof mapThemes) => void;
toggle: () => void;
onThemeChange?: (theme: keyof typeof mapThemes) => void;
}
function createThemeManager(map: L.Map): ThemeManager {
let currentTheme: keyof typeof mapThemes = 'light';
let tileLayer: L.TileLayer;
const initTheme = mapThemes[currentTheme];
tileLayer = L.tileLayer(initTheme.tileUrl, {
attribution: initTheme.attribution,
...initTheme.options
}).addTo(map);
const manager: ThemeManager = {
currentTheme,
setTheme(theme: keyof typeof mapThemes) {
if (currentTheme === theme) return;
const newTheme = mapThemes[theme];
const container = map.getContainer();
container.style.transition = 'opacity 0.3s ease';
container.style.opacity = '0.5';
setTimeout(() => {
map.removeLayer(tileLayer);
tileLayer = L.tileLayer(newTheme.tileUrl, {
attribution: newTheme.attribution,
...newTheme.options
}).addTo(map);
document.body.classList.remove(`${currentTheme}-mode`);
document.body.classList.add(`${theme}-mode`);
currentTheme = theme;
manager.currentTheme = theme;
container.style.opacity = '1';
if (manager.onThemeChange) {
manager.onThemeChange(theme);
}
}, 300);
},
toggle() {
const nextTheme = currentTheme === 'light' ? 'dark' : 'light';
this.setTheme(nextTheme);
}
};
return manager;
}
CSS for Dark Mode:
.dark-mode .leaflet-container {
background: var(--color-dark-bg, #111827);
}
.dark-mode .leaflet-control-zoom a,
.dark-mode .leaflet-control-layers,
.dark-mode .leaflet-custom-control {
background: var(--color-dark-surface, #1f2937);
color: var(--color-dark-text, #f3f4f6);
border-color: rgba(255, 255, 255, 0.1);
}
.dark-mode .leaflet-control-zoom a:hover {
background: var(--color-dark-hover, #374151);
}
.dark-mode .custom-popup .leaflet-popup-content-wrapper {
background: var(--color-dark-surface, #1f2937);
color: var(--color-dark-text, #f3f4f6);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.dark-mode .custom-popup .leaflet-popup-tip {
background: var(--color-dark-surface, #1f2937);
border-color: rgba(255, 255, 255, 0.1);
}
.dark-mode .popup-header {
background: var(--color-dark-elevated, #374151);
border-bottom-color: rgba(255, 255, 255, 0.1);
}
.dark-mode .popup-footer {
background: var(--color-dark-elevated, #374151);
border-top-color: rgba(255, 255, 255, 0.1);
}
.dark-mode .popup-description {
color: var(--color-dark-text-secondary, #d1d5db);
}
.dark-mode .meta-label {
color: var(--color-dark-text-tertiary, #9ca3af);
}
.dark-mode .meta-value {
color: var(--color-dark-text, #f3f4f6);
}
.dark-mode .leaflet-control-attribution {
background: rgba(31, 41, 55, 0.9);
color: var(--color-dark-text-secondary, #d1d5db);
}
.dark-mode .leaflet-control-attribution a {
color: var(--color-primary-light, #93c5fd);
}
.leaflet-container {
transition: background-color 0.3s ease;
}
Performance Optimization
Canvas Renderer for Large Datasets
Pattern:
interface Point {
lat: number;
lng: number;
color: string;
radius: number;
}
function createOptimizedMarkers(
map: L.Map,
points: Point[]
): L.FeatureGroup {
const canvas = L.canvas({ padding: 0.5, tolerance: 10 });
const markers: L.CircleMarker[] = points.map(point => {
return L.circleMarker([point.lat, point.lng], {
renderer: canvas,
radius: point.radius,
fillColor: point.color,
color: '#fff',
weight: 1,
fillOpacity: 0.8
});
});
return L.featureGroup(markers).addTo(map);
}
Geometry Simplification
Pattern:
import * as turf from '@turf/turf';
function simplifyGeoJSON(
geojson: GeoJSON.FeatureCollection,
tolerance: number = 0.001
): GeoJSON.FeatureCollection {
return turf.simplify(geojson, {
tolerance,
highQuality: false
});
}
Throttled Event Handlers
Pattern:
function throttle<T extends (...args: any[]) => any>(
func: T,
limit: number
): (...args: Parameters<T>) => void {
let inThrottle: boolean;
return function(this: any, ...args: Parameters<T>) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout;
return function(this: any, ...args: Parameters<T>) {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this, args), wait);
};
}
map.on('moveend', debounce(() => {
loadMarkersInViewport();
}, 300));
map.on('zoom', throttle(() => {
updateMarkerSizes();
}, 150));
Viewport-Based Marker Management
Pattern:
interface ViewportManager {
update: () => void;
destroy: () => void;
}
function createViewportManager(
map: L.Map,
allMarkers: L.Marker[]
): ViewportManager {
const visibleMarkers = new Set<L.Marker>();
function updateVisibleMarkers() {
const bounds = map.getBounds();
const currentZoom = map.getZoom();
allMarkers.forEach(marker => {
const latLng = marker.getLatLng();
const isInBounds = bounds.contains(latLng);
if (isInBounds) {
if (!visibleMarkers.has(marker)) {
marker.addTo(map);
visibleMarkers.add(marker);
}
} else {
if (visibleMarkers.has(marker)) {
map.removeLayer(marker);
visibleMarkers.delete(marker);
}
}
});
}
const throttledUpdate = throttle(updateVisibleMarkers, 200);
map.on('moveend zoomend', throttledUpdate);
return {
update: throttledUpdate,
destroy() {
map.off('moveend zoomend', throttledUpdate);
visibleMarkers.forEach(marker => map.removeLayer(marker));
visibleMarkers.clear();
}
};
}
Accessibility Patterns
ARIA Labels and Keyboard Navigation
Pattern:
interface AccessibilityOptions {
mapLabel?: string;
announceOnMove?: boolean;
keyboardNavigation?: boolean;
}
function enhanceAccessibility(
map: L.Map,
markers: L.Marker[],
options: AccessibilityOptions = {}
): void {
const {
mapLabel = 'Interactive map showing locations',
announceOnMove = true,
keyboardNavigation = true
} = options;
const container = map.getContainer();
container.setAttribute('role', 'application');
container.setAttribute('aria-label', mapLabel);
container.setAttribute('tabindex', '0');
if (announceOnMove) {
const announcer = L.DomUtil.create('div', 'sr-only');
announcer.setAttribute('aria-live', 'polite');
announcer.setAttribute('aria-atomic', 'true');
container.appendChild(announcer);
map.on('moveend', () => {
const center = map.getCenter();
const zoom = map.getZoom();
announcer.textContent = `Map moved. Center: ${center.lat.toFixed(4)}, ${center.lng.toFixed(4)}. Zoom level: ${zoom}`;
});
}
if (keyboardNavigation) {
markers.forEach((marker, index) => {
const element = marker.getElement();
if (element) {
element.setAttribute('tabindex', '0');
element.setAttribute('role', 'button');
element.setAttribute('aria-label', `Marker ${index + 1}`);
L.DomEvent.on(element, 'keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
marker.openPopup();
}
});
L.DomEvent.on(element, 'focus', () => {
element.classList.add('marker-focused');
});
L.DomEvent.on(element, 'blur', () => {
element.classList.remove('marker-focused');
});
}
});
}
const skipLink = L.DomUtil.create('a', 'skip-map-link');
skipLink.href = '#after-map';
skipLink.textContent = 'Skip map';
skipLink.setAttribute('tabindex', '0');
container.parentElement?.insertBefore(skipLink, container);
}
CSS for Accessibility:
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
.skip-map-link {
position: absolute;
top: -40px;
left: 0;
background: var(--color-primary, #3b82f6);
color: white;
padding: 8px 16px;
text-decoration: none;
border-radius: 4px;
z-index: 10000;
font-weight: 600;
}
.skip-map-link:focus {
top: 10px;
left: 10px;
}
.leaflet-container:focus {
outline: 3px solid var(--color-primary, #3b82f6);
outline-offset: 2px;
}
.marker-focused {
outline: 3px solid var(--color-primary, #3b82f6);
outline-offset: 3px;
border-radius: 50%;
z-index: 10000 !important;
}
@media (prefers-contrast: high) {
.leaflet-control-zoom a,
.custom-popup .leaflet-popup-content-wrapper {
border: 2px solid currentColor;
}
.marker-focused {
outline-width: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.custom-svg-icon,
.marker-pin,
.popup-btn,
.toggle-slider::before {
transition: none !important;
animation: none !important;
}
}
.leaflet-control-zoom a:focus-visible,
.popup-btn:focus-visible,
.control-btn:focus-visible {
outline: 3px solid var(--color-primary, #3b82f6);
outline-offset: 2px;
}
Best Practices
Performance Best Practices
- Use Canvas Renderer - For 500+ markers, use canvas renderer instead of SVG
- Implement Clustering - Always cluster when displaying more than 100 markers
- Simplify Geometries - Reduce polygon complexity for faster rendering
- Throttle Events - Use throttle/debounce for expensive map event handlers
- Viewport Culling - Only render markers within current viewport
- Lazy Load Popups - Create popup content when opened, not when marker created
- Use Feature Groups - Group related markers for batch operations
- Optimize Images - Compress marker icons and use SVG when possible
- Clean Up Layers - Remove unused layers and markers from map
- Test on Mobile - Always test performance on mobile devices
Design Best Practices
- Consistent Colors - Use design tokens for all colors
- Clear Hierarchy - Make important markers visually prominent
- Readable Text - Ensure sufficient contrast in popups and tooltips
- Touch Targets - Make markers and buttons at least 44x44px for touch
- Loading States - Show loading indicators for async operations
- Empty States - Handle cases where no data is available
- Error States - Display friendly error messages
- Responsive Design - Adapt controls and popups for mobile
- Brand Integration - Match map styling to overall design system
- User Feedback - Provide visual feedback for all interactions
Accessibility Best Practices
- ARIA Labels - Add descriptive labels to all interactive elements
- Keyboard Navigation - Support Tab, Enter, and Space keys
- Focus Indicators - Show clear focus states for keyboard users
- Screen Readers - Provide text alternatives for visual information
- Color Contrast - Ensure WCAG AA compliance (4.5:1 minimum)
- Skip Links - Allow keyboard users to skip complex map interfaces
- Announcements - Use aria-live for dynamic content changes
- Reduced Motion - Respect prefers-reduced-motion preference
- High Contrast - Support high contrast mode
- Testing - Test with actual screen readers and keyboard only
Summary
This Leaflet Cookbook provides comprehensive patterns for:
- Custom Marker Icons - SVG and image-based markers with categories
- Rich Popups - Feature-rich popups with images, metadata, and actions
- Custom Controls - Multi-function control panels with theme switching
- GeoJSON Styling - Data-driven choropleth and point visualizations
- Marker Clustering - Performance-optimized clustering for large datasets
- Dark Mode - Complete theme switching system
- Performance - Canvas rendering, throttling, and viewport culling
- Accessibility - ARIA labels, keyboard navigation, and screen reader support
All patterns include:
- Complete TypeScript code with type definitions
- Comprehensive CSS styling with CSS custom properties
- Design token integration for theming
- Performance optimization techniques
- Accessibility compliance (WCAG AA)
- Real-world usage examples
- Best practices and recommendations