Leaflet with react-leaflet provides free, open-source interactive maps — <MapContainer center={[lat, lng]} zoom={13}> sets the initial view. <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution="© OpenStreetMap contributors"> adds OpenStreetMap tiles. <Marker position={[lat, lng]}> places markers. <Popup> shows callouts on click. <Circle>, <Polygon>, and <Polyline> draw vector shapes. <GeoJSON data={featureCollection} style={...} onEachFeature={...}> renders GeoJSON. useMap() returns the Leaflet map instance for flyTo, fitBounds, and setView. <LayersControl> creates a toggle panel. L.divIcon({ html, className }) creates custom HTML markers. L.markerClusterGroup() clusters nearby markers. <FeatureGroup ref={ref}> wraps draw layers. Claude Code generates Leaflet maps, GeoJSON layers, clustering, and custom marker designs.
CLAUDE.md for Leaflet
## Leaflet Stack
- Version: leaflet >= 1.9, react-leaflet >= 4.2
- Setup: import "leaflet/dist/leaflet.css" in root layout; fix default icon: delete L.Icon.Default.prototype._getIconUrl; L.Icon.Default.mergeOptions({ iconRetinaUrl, iconUrl, shadowUrl })
- MapContainer: <MapContainer center={[lat, lng]} zoom={13} className="h-full w-full" zoomControl={false}>
- Tiles: <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'/>
- Marker: <Marker position={[lat, lng]}><Popup><p>Content</p></Popup></Marker>
- Imperative: const map = useMap(); map.flyTo([lat, lng], 14, { duration: 1 })
- GeoJSON: <GeoJSON data={fc} style={(f) => ({ color: f?.properties?.color ?? "#6366f1" })} onEachFeature={(f, layer) => layer.bindPopup(f.properties.name)} />
Map Component
// components/map/LeafletMap.tsx — react-leaflet map with markers and controls
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import { MapContainer, TileLayer, Marker, Popup, ZoomControl, useMap, useMapEvents } from "react-leaflet"
import type { Map as LeafletMap } from "leaflet"
import "leaflet/dist/leaflet.css"
// Fix default marker icons (Next.js / webpack asset issue)
function fixLeafletIcons() {
if (typeof window === "undefined") return
// eslint-disable-next-line @typescript-eslint/no-var-requires
const L = require("leaflet")
delete (L.Icon.Default.prototype as any)._getIconUrl
L.Icon.Default.mergeOptions({
iconRetinaUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon-2x.png",
iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png",
shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png",
})
}
type Place = {
id: string
name: string
lat: number
lng: number
category?: string
description?: string
}
// Inner component that can access the map instance via hooks
function MapController({ focusedPlace }: { focusedPlace: Place | null }) {
const map = useMap()
useEffect(() => {
if (focusedPlace) {
map.flyTo([focusedPlace.lat, focusedPlace.lng], 15, { duration: 0.8, easeLinearity: 0.5 })
}
}, [map, focusedPlace])
return null
}
function ClickRecorder({ onMapClick }: { onMapClick: (lat: number, lng: number) => void }) {
useMapEvents({
click(e) {
onMapClick(e.latlng.lat, e.latlng.lng)
},
})
return null
}
interface LeafletMapProps {
places: Place[]
center?: [number, number]
zoom?: number
onPlaceSelect?: (place: Place) => void
onMapClick?: (lat: number, lng: number) => void
}
export function LeafletMap({
places,
center = [51.505, -0.09],
zoom = 13,
onPlaceSelect,
onMapClick,
}: LeafletMapProps) {
const [focusedPlace, setFocusedPlace] = useState<Place | null>(null)
const [isReady, setIsReady] = useState(false)
useEffect(() => {
fixLeafletIcons()
setIsReady(true)
}, [])
const handleMarkerClick = useCallback((place: Place) => {
setFocusedPlace(place)
onPlaceSelect?.(place)
}, [onPlaceSelect])
if (!isReady) {
return <div className="w-full h-full bg-muted animate-pulse rounded-xl" />
}
return (
<MapContainer
center={center}
zoom={zoom}
className="w-full h-full rounded-xl z-0"
zoomControl={false}
>
<TileLayer
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
maxZoom={19}
/>
<ZoomControl position="topright" />
<MapController focusedPlace={focusedPlace} />
{onMapClick && <ClickRecorder onMapClick={onMapClick} />}
{places.map((place) => (
<Marker
key={place.id}
position={[place.lat, place.lng]}
eventHandlers={{ click: () => handleMarkerClick(place) }}
>
<Popup maxWidth={240} closeButton={false}>
<div className="py-1 px-0.5">
<p className="font-semibold text-sm leading-tight">{place.name}</p>
{place.category && (
<p className="text-xs text-gray-500 capitalize mt-0.5">{place.category}</p>
)}
{place.description && (
<p className="text-xs text-gray-700 mt-1.5 leading-relaxed">{place.description}</p>
)}
</div>
</Popup>
</Marker>
))}
</MapContainer>
)
}
GeoJSON Choropleth Map
// components/map/ChoroplethMap.tsx — GeoJSON regions with dynamic fill color
"use client"
import { useEffect, useState } from "react"
import { MapContainer, TileLayer, GeoJSON, ZoomControl } from "react-leaflet"
import type { Feature, GeoJsonObject } from "geojson"
import type { Layer, PathOptions } from "leaflet"
import "leaflet/dist/leaflet.css"
type RegionProperties = {
name: string
value: number
}
function getColor(value: number, max: number): string {
const ratio = value / max
if (ratio > 0.8) return "#4f46e5"
if (ratio > 0.6) return "#6366f1"
if (ratio > 0.4) return "#818cf8"
if (ratio > 0.2) return "#a5b4fc"
return "#c7d2fe"
}
interface ChoroplethMapProps {
geojson: GeoJsonObject
valueKey?: string
}
export function ChoroplethMap({ geojson, valueKey = "value" }: ChoroplethMapProps) {
const [maxValue, setMaxValue] = useState(100)
const [hoveredRegion, setHoveredRegion] = useState<string | null>(null)
useEffect(() => {
if ((geojson as any).features) {
const values = ((geojson as any).features as Feature[]).map(
(f) => (f.properties as RegionProperties).value ?? 0,
)
setMaxValue(Math.max(...values, 1))
}
}, [geojson])
function styleFeature(feature?: Feature): PathOptions {
const value = (feature?.properties as RegionProperties)?.[valueKey as keyof RegionProperties] as number ?? 0
const isHovered = hoveredRegion === (feature?.properties as RegionProperties)?.name
return {
fillColor: getColor(value, maxValue),
fillOpacity: isHovered ? 0.9 : 0.7,
color: isHovered ? "#312e81" : "#fff",
weight: isHovered ? 2.5 : 1,
}
}
function onEachFeature(feature: Feature, layer: Layer) {
const props = feature.properties as RegionProperties
const pct = ((props.value / maxValue) * 100).toFixed(1)
layer.bindTooltip(
`<div style="font-size:13px"><strong>${props.name}</strong><br/>Value: ${props.value.toLocaleString()} (${pct}%)</div>`,
{ sticky: true, opacity: 0.95 },
)
layer.on({
mouseover: () => setHoveredRegion(props.name),
mouseout: () => setHoveredRegion(null),
})
}
return (
<div className="relative w-full h-full">
<MapContainer
center={[20, 0]}
zoom={2}
className="w-full h-full"
zoomControl={false}
worldCopyJump
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}{r}.png"
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <a href="https://carto.com/attributions">CARTO</a>'
/>
<ZoomControl position="bottomright" />
<GeoJSON
key={JSON.stringify(geojson).slice(0, 40)}
data={geojson}
style={styleFeature}
onEachFeature={onEachFeature}
/>
</MapContainer>
{/* Legend */}
<div className="absolute bottom-8 left-4 bg-white rounded-xl shadow-md p-3 z-[1000] text-xs">
<p className="font-medium mb-2 text-gray-700">Value range</p>
{[
["#4f46e5", "> 80%"],
["#6366f1", "60–80%"],
["#818cf8", "40–60%"],
["#a5b4fc", "20–40%"],
["#c7d2fe", "< 20%"],
].map(([color, label]) => (
<div key={label} className="flex items-center gap-2 mb-1">
<span className="size-3 rounded-sm inline-block" style={{ backgroundColor: color }} />
<span className="text-gray-600">{label}</span>
</div>
))}
</div>
</div>
)
}
Custom Icon Markers
// components/map/CustomMarkers.tsx — category icons using DivIcon
"use client"
import L from "leaflet"
import { Marker, Popup } from "react-leaflet"
const CATEGORY_ICONS: Record<string, { emoji: string; color: string }> = {
restaurant: { emoji: "🍽️", color: "#ef4444" },
cafe: { emoji: "☕", color: "#92400e" },
park: { emoji: "🌳", color: "#16a34a" },
hotel: { emoji: "🏨", color: "#7c3aed" },
museum: { emoji: "🏛️", color: "#0284c7" },
default: { emoji: "📍", color: "#6366f1" },
}
function createCategoryIcon(category: string) {
const { emoji, color } = CATEGORY_ICONS[category] ?? CATEGORY_ICONS.default
return L.divIcon({
className: "",
html: `
<div style="
background: ${color};
width: 36px; height: 36px;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
border: 2px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,.3);
display: flex; align-items: center; justify-content: center;
">
<span style="transform: rotate(45deg); font-size: 16px; line-height: 1">${emoji}</span>
</div>
`,
iconSize: [36, 36],
iconAnchor: [18, 36],
popupAnchor: [0, -36],
})
}
type POI = {
id: string
name: string
lat: number
lng: number
category: string
address?: string
}
export function CategoryMarker({ poi }: { poi: POI }) {
return (
<Marker
position={[poi.lat, poi.lng]}
icon={createCategoryIcon(poi.category)}
>
<Popup maxWidth={200}>
<div className="py-1">
<p className="font-semibold text-sm">{poi.name}</p>
<p className="text-xs text-gray-500 capitalize">{poi.category}</p>
{poi.address && <p className="text-xs text-gray-600 mt-1">{poi.address}</p>}
</div>
</Popup>
</Marker>
)
}
For the Mapbox alternative when superior vector tiles, 3D terrain rendering, custom map styles via Mapbox Studio, a richer GL-based rendering pipeline, or a built-in geocoding/directions API is required — Mapbox GL JS has far more rendering power while Leaflet is simpler, free without any API key for standard tile use, and easier for server-side rendering, see the Mapbox guide. For the Google Maps alternative when access to Google’s comprehensive POI database, Street View embedding, the Places Autocomplete widget, or the Directions/Distance Matrix APIs is essential — Google Maps is unmatched for rich place data while Leaflet gives more design flexibility and zero usage costs for OpenStreetMap tiles, see the Google Maps guide. The Claude Skills 360 bundle includes Leaflet skill sets covering GeoJSON layers, clustering, and choropleth maps. Start with the free tier to try open-source map generation.