Maps
Interactive Leaflet maps with markers and search.
Live demo
01Pan and zoom an OpenStreetMap-tiled map. Drag, scroll, or use the on-screen controls.
Recipes
- Static "where we are" minimap. Use
MapBasicwithheight={240},enableScrollZoom={false},showZoomControl={false}. Drop into the footer of a contact card. - Dense category map. Pass dozens of markers to
MapMarkerswithshowCategoriesand let users filter. Above ~200 pins, plan to add tile-side clustering βenableClusteringis reserved on the props but not yet implemented. - Mobile portrait stage. Wrap
MapBasicin a 9:16 frame and hide attribution. The internal Leaflet zoom buttons remain in the top-right and stay thumb-reachable. - Satellite/street toggle. Today the tile URL is hardcoded to OpenStreetMap. To add satellite, extend
MapBasicwith atileLayerprop and pass an Esri World Imagery URL β keep the OSM layer as the fallback for attribution.
Implementation
02<script lang="ts">
import MapLive from '$lib/components/MapLive.svelte';
</script>
β
<MapLive
centre={[51.5074, -0.1278]}
zoom={10}
markers={[{ id: 1, lat: 51.5074, lng: -0.1278, title: 'London' }]}
/>Each map component wraps Leaflet behind a typed Svelte 5 surface. MapBasic mounts a tile layer plus pan/zoom; MapSearch hits the Nominatim geocoder for live suggestions; MapMarkers renders database-loaded points with category filtering; MapLive lets users add and drag markers, syncing via $bindable state. Leaflet only runs client-side, so the demo is gated on `browser` from $app/environment.
Logic explainer
03What Does It Do? (Plain English)
A live, editable map. The user clicks anywhere on the tiles to drop a pin, drags pins to reposition them, and uses an inline popup to rename or delete each one. The full marker list is bindable, so the parent component always sees an up-to-date array β useful for "save my favourite places", route planning, or any UI where users curate a small set of locations.
Think of it as a digital corkboard pinned to a paper map: you place a thumbtack with a single tap, scribble a label on it, and slide it around with your finger. The map underneath is OpenStreetMap tiles served via Leaflet β battle-tested map plumbing the component delegates to rather than reimplementing.
How It Works (Pseudo-Code)
state:
markers = [] // bindable, parent-controlled
isAddMode = true // toggleable from control bar
markerMap = SvelteMap<id, LeafletMarker>
nextMarkerId = max(existing IDs) + 1
canAddMore = maxMarkers === 0 || markers.length < maxMarkers
mount ($effect):
1. Dynamic-import 'leaflet' (SSR-safe β module never loaded on server)
2. Read prefers-reduced-motion to gate Leaflet's zoom/fade animations
3. Create map at center/zoom; attach OSM tile layer
4. Add zoom control bottom-right (avoids overlapping our control bar)
5. Create a LayerGroup for markers
6. Subscribe to map 'click' event:
if isAddMode AND canAddMore AND enableAddMode:
addMarkerAtPosition(event.latlng)
7. Replay any existing markers passed in via prop
cleanup:
mapInstance.remove(); reset markerMap
addMarkerAtPosition(latlng):
1. Build MapMarker { id: nextMarkerId++, position, title, description }
2. markers = [...markers, newMarker] // immutable update fires reactivity
3. await addLeafletMarker(newMarker, animate=true)
4. onMarkerAdd?.(newMarker)
addLeafletMarker(data, animate):
1. Create draggable L.marker at data.position
2. bindPopup with edit form HTML; configure autoPan padding so popup is fully visible
3. on 'popupopen' β setupPopupHandlers() (rebinds save/delete)
4. on 'dragend' β updateMarkerPosition(id, newLatLng)
5. layerGroup.addLayer(marker); markerMap.set(id, marker)
6. if animate: add .marker-animate-in class, remove after 300ms
popup save handler:
e.stopPropagation() // critical β otherwise map 'click' fires
updateMarkerDetails(id, title, description)
marker.closePopup()
popup delete handler:
e.stopPropagation()
removeMarker(id)The Core Concept: Tile Pyramids and Web Mercator
Every web map you have ever scrolled is a pyramid of pre-rendered image tiles, each 256Γ256 pixels, organised by zoom level. At zoom 0 the entire world fits in one tile; at zoom 1 there are 4 tiles; at zoom z there are 4βΏ tiles. Leaflet's job is to figure out which tiles are visible in the current viewport, fetch them from a server, and stitch them edge-to-edge.
The projection that makes those tiles align is Web Mercator (EPSG:3857). It is a cylindrical projection that wraps the globe around a cylinder tangent to the equator, then unrolls it flat. The maths:
x = R Β· (longitude in radians)
y = R Β· ln( tan(Ο/4 + latitude/2) )This is the same transform Google, OpenStreetMap, and Leaflet all use, which is why their tiles are interchangeable. The pleasant side-effect is that loxodromes (constant-bearing lines) are straight on the map β useful for navigation, the original purpose of the projection. The painful side-effect is that Greenland looks bigger than Africa. For most UI work, the trade-off is worth it: pinching to zoom feels natural because the projection is conformal (angle-preserving locally).
The component never touches projection maths directly β Leaflet handles it β but understanding why a marker appears where it does requires knowing that [lat, lng] are converted through this transform before becoming pixel coordinates. That is also why marker positions are stored as LatLng (geographic) rather than {x, y} (screen): geographic coordinates are stable across zoom levels, screen coordinates are not.
Performance: Layer Groups and Marker Volume
Leaflet's L.LayerGroup is the secret to keeping a map with hundreds of markers responsive. Instead of attaching every marker directly to the map (which forces a redraw of the whole layer stack on each addition), the component groups them into a single layer that Leaflet can show/hide, clear, or remove as one operation:
markerLayer = L.layerGroup().addTo(map);
markerLayer.addLayer(newMarker); // O(1) attach
markerLayer.clearLayers(); // O(1) remove all β used by Clear all buttonFor datasets larger than a few hundred markers, swap the LayerGroup for Leaflet.markercluster, which automatically clusters nearby markers into a single bubble showing the count, then expands them as the user zooms in. The component does not bundle markercluster β it would defeat the "minimal dependency" promise β but the LayerGroup pattern is the natural extension point.
Tile loading itself is handled by the browser: Leaflet sets <img> src attributes and lets HTTP/2 multiplex the requests. There is no manual viewport culling, no prefetch logic, no service worker. On a slow network, tiles load progressively from low-resolution placeholders to the full-resolution tile (Leaflet's default behaviour).
The dynamic await import('leaflet') keeps Leaflet's ~150 KB gzip out of the initial bundle for any route that does not actually use a map. SSR builds never touch the module, because the import lives inside $effect which only runs after typeof window !== 'undefined'.
Accessibility Deep-Dive
Maps are notoriously hard to make accessible β they are inherently visual, spatial, and pointer-driven β but the component does what it can:
role="application"on the map element tells screen readers to pass keystrokes through rather than treating the map as a document. Leaflet then handles arrow-key panning and+/-zooming natively.- Popup forms are keyboard-reachable. When a popup opens the input gets focus;
Tabmoves through title β description β save β delete;Escapecloses the popup (Leaflet default). aria-pressedon the add-mode toggle reflects the current mode for AT users.aria-live="polite"announces marker count changes and the "Click anywhere on the map to add a marker" hint.- All popup HTML is escaped via
escapeHtml()from$lib/htmlUtilsβ user-typed titles and descriptions cannot inject script tags through the popup template.
What the component cannot do is describe map content semantically β there is no list of "things on this map" exposed to screen readers. For applications where that matters, render an off-screen <ul> of marker titles in parallel; the GlobePresence component does exactly this with .sr-only.
State Flow Diagram
βββββββββββββββββββββββ
β IDLE β isAddMode=false
β no clicks add pinsβ canAddMore=*
ββββββββββββ¬βββββββββββ
β click toggle button
βΌ
βββββββββββββββββββββββ
β ADD MODE ACTIVE β isAddMode=true
β crosshair cursor β hint banner visible
ββββββββββββ¬βββββββββββ
β map click
βΌ
βββββββββββββββββββββββ
β MARKER PLACED β markers = [...markers, new]
β drop animation β onMarkerAdd fires
ββββββββββββ¬βββββββββββ
β click marker
βΌ
βββββββββββββββββββββββ
β POPUP OPEN β edit form visible
β focus β title β save / delete bound
ββββββββ¬ββββββββ¬βββββββ
save click β β delete click
βΌ βΌ
βββββββββββββββββββββββ
β MARKER UPDATED β βββββββββββββββββββββββ
β or REMOVED β βββΆ β MAX REACHED β
β array re-emitted β β canAddMore=false β
βββββββββββββββββββββββ β banner: "Maximum" β
βββββββββββββββββββββββProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
markers |
MapMarker[] |
[] |
Bindable array of markers. Mutate from the parent and the map syncs. |
center |
LatLng |
DEFAULT_MAP_CENTER (London) |
Initial map centre. Ignored after mount. |
zoom |
number |
13 |
Initial zoom level (1β18). |
height |
number |
500 |
Map container height in pixels. |
enableAddMode |
boolean |
true |
Whether the add-mode toggle is rendered at all. |
animateNewMarkers |
boolean |
true |
Play the drop animation on newly placed markers. |
maxMarkers |
number |
0 |
Hard cap on marker count. 0 means unlimited. |
onMarkerAdd |
(m: MapMarker) => void |
undefined |
Fires after a marker is placed. |
onMarkerRemove |
(m: MapMarker) => void |
undefined |
Fires after a marker is deleted (including via Clear all). |
Edge Cases
| Situation | Behaviour |
|---|---|
| SSR render | The $effect short-circuits when mapContainer is undefined β Leaflet never loads on the server. The container is rendered as an empty <div>. |
prefers-reduced-motion: reduce |
Leaflet's zoom/fade animations are disabled at construction; the marker drop animation is overridden by a @media rule. |
maxMarkers reached |
canAddMore flips to false; the hint banner shows "Maximum markers reached"; map clicks no longer add. |
| User drags a marker into the sea | Position is stored verbatim β there is no land/water validation. Add it in the parent if needed. |
| Popup save without typing a title | Falls back to the placeholder string 'Untitled'. Empty descriptions are stored as empty strings. |
Parent mutates markers directly |
Existing Leaflet markers do not re-sync automatically β they were created on initial mount. Treat markers as bindable, not as a one-way prop. |
| Clear all button click | Iterates markers, fires onMarkerRemove for each, then resets the array and the LayerGroup in one operation. |
| Hundreds of markers | LayerGroup keeps render time linear, but click-to-add latency degrades past ~500 markers β switch to leaflet.markercluster for those scales. |
| Offline / tile server unreachable | OSM tiles fail to load and show as grey squares; the map remains interactive (pan, zoom, marker placement still work). |
Dependencies
- leaflet (~150 KB gzip) β Industry-standard open-source map library. Building map plumbing natively (tile pyramid, projection, layer compositor, marker hit-testing) would take 100+ hours and still not match Leaflet's mobile gesture handling. Justified external dependency.
- @types/leaflet β TypeScript definitions, dev-only.
- OpenStreetMap tile servers β Free public tiles. For production traffic use a proper tile provider (Mapbox, Stadia, MapTiler) β OSM's usage policy is intended for development and small-traffic hobbyist sites.
- Leaflet CSS β Must be loaded globally (in
app.htmlor via a stylesheet import). Without it tiles render but controls and popups are unstyled. escapeHtmlfrom$lib/htmlUtilsβ XSS protection for user-typed popup content.
File Structure
src/lib/components/MapLive.svelte # primary "click to add" implementation
src/lib/components/MapBasic.svelte # static viewer with marker prop
src/lib/components/MapMarkers.svelte # markers-only layer for composition
src/lib/components/MapSearch.svelte # Nominatim geocoding search box
src/lib/components/Maps.md # this file (rendered inside ComponentPageShell)
src/routes/maps/+page.svelte # demo page
src/lib/types.ts # MapLiveProps, MapMarker, LatLng
src/lib/constants.ts # DEFAULT_MAP_CENTER, FALLBACK_MARKERS
src/lib/mapUtils.ts # calculateMapBounds β fit-to-markers helper
src/lib/htmlUtils.ts # escapeHtml β popup XSS guard
database/schema_maps.sql # map_markers schema (optional Neon table)API
04| Feature | MapBasic | MapSearch | MapMarkers | MapLive |
|---|---|---|---|---|
| Pan & zoom | Yes | Yes | Yes | Yes |
| Geocoding search | β | Nominatim | β | β |
| Multiple markers | β | Single | Yes | Yes |
| Category filtering | β | β | Yes | β |
| Click to add | β | β | β | Yes |
| Drag to edit | β | β | β | Yes |
| Database-backed | β | β | Yes | Optional |
| Best for | Static display | Place lookup | Visualisation | User input |