Claude Code for Leaflet: Open-Source Interactive Maps — Claude Skills 360 Blog
Blog / Frontend / Claude Code for Leaflet: Open-Source Interactive Maps
Frontend

Claude Code for Leaflet: Open-Source Interactive Maps

Published: June 17, 2027
Read time: 6 min read
By: Claude Skills 360

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='&copy; <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='&copy; <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='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <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.

Keep Reading

Frontend

Claude Code for Chart.js Advanced: Custom Plugins and Mixed Charts

Advanced Chart.js patterns with Claude Code — chart.register() for tree-shaking, mixed chart types combining bar and line, custom plugin API with beforeDraw and afterDatasetsDraw hooks, ScriptableContext for computed colors, ChartDataLabels plugin for value labels, chartjs-plugin-zoom for pan and zoom, custom gradient fills via ctx.createLinearGradient, ChartJS annotation plugin for threshold lines, streaming data with chartjs-plugin-streaming, and react-chartjs-2 with useRef and chart instance.

6 min read Jun 27, 2027
Frontend

Claude Code for Nivo: Rich SVG and Canvas Charts

Build rich data visualizations with Nivo and Claude Code — ResponsiveLine and ResponsiveBar for adaptive charts, ResponsiveHeatMap for matrix data, ResponsiveTreeMap for hierarchal data, ResponsiveSunburst for nested proportions, ResponsiveChord for relationship diagrams, ResponsiveCalendar for activity heat maps, ResponsiveNetwork for force graphs, NivoTheme for consistent styling, tooltip customization with sliceTooltip, and motion config for spring animations.

6 min read Jun 26, 2027
Frontend

Claude Code for Victory Charts: React Native and Web Charts

Build cross-platform charts with Victory and Claude Code — VictoryChart, VictoryLine, VictoryBar, and VictoryScatter for web and React Native, VictoryPie for donut charts, VictoryArea for stacked areas, VictoryAxis for custom axes, VictoryTooltip and VictoryVoronoiContainer for hover tooltips, VictoryBrushContainer for range selection, VictoryZoomContainer for pan and zoom, VictoryLegend for series labels, custom theme with VictoryTheme, and VictoryStack for grouped bars.

6 min read Jun 25, 2027

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free