SakaniUI

Map

An interactive tile map with markers, popups and controls, and a 3D dotted globe. No map library, fully themed with tokens.

Map draws flat map tiles and places MapMarker and MapPopup children by latitude and longitude. Drag to pan, scroll or pinch to zoom at the pointer, double-click to zoom in. Tiles are plain images on a Web Mercator grid, so it adds no dependency; the default tiles are OpenStreetMap's, which suit development and light use. For production traffic bring your own provider with tileUrl (see Tiles below).

© OpenStreetMap contributors

Markers

Three types, each with Default, Hover and Selected states that match the Figma Map Marker set. Hover works on every marker (the one under the pointer rises above its neighbours); selected is yours to set. The pin is anchored at its tip, the dot and cluster at their centre.

© OpenStreetMap contributors

Popup

A card that opens above a place. The description, the close button and the pointer are each optional, and it takes children for actions.

© OpenStreetMap contributors

Controls

Zoom buttons are on by default (Default, Hover and Disabled states, matching Map Control Button); add locate for a button that centres on the user. The same buttons are exported as MapControls and MapControlButton for custom overlays.

controls={false}

© OpenStreetMap contributors

locate, at maxZoom (zoom in is disabled)

Dark mode

The map follows the page's theme, so under .dark its tiles are darkened to near-black land with blue water and roads darker than the land (tune it with --map-tile-filter). Markers, controls and popups re-theme with the tokens. Use the moon button on any preview here to see it, or pass theme="dark" to make one map dark on a light page, as below. For a provider's own dark style, give tileUrlDark.

© OpenStreetMap contributors

Live data

Keep positions in state, update them when your feed delivers (WebSocket, server-sent events, polling), and give the markers glide of about the interval between updates: they slide to each new place instead of jumping, and a popup that points at one follows it. Here a new position arrives every two seconds; toggle glide to compare.

© OpenStreetMap contributors

Many places

Don't map thousands of MapMarkers yourself. MapClusterLayer groups nearby places into counted clusters that zoom to their places when clicked, regroups as you zoom, and renders only what is in view. This map has 2,000 places; a zoom step takes about 5 ms with 10,000.

© OpenStreetMap contributors

Tiles and attribution

Any raster tile server works: give its {z}/{x}/{y} template and the credit it requires. OpenStreetMap's own servers are for light use; their usage policy asks heavier sites to use a provider or host their own tiles.

// Any {z}/{x}/{y} raster tile server. Pass its attribution too.
<Map
  tileUrl="https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png?key=YOUR_KEY"
  attribution={<>© MapTiler © OpenStreetMap contributors</>}
/>

Your own overlays

Inside a Map, useMapContext() projects places into the map's pixels and can move the view.

import { useMapContext } from '@sakaniui/react';

function Boundary({ corners }: { corners: LatLng[] }) {
  const { project, flyTo, fitBounds } = useMapContext();   // inside <Map>
  const points = corners.map(project);                      // container pixels, or null when far off-screen
  /* draw anything you like at those pixels */
  return <button onClick={() => fitBounds(corners)}>Fit</button>;
}
MemberWhat it does
project(position)[lat, lng] to { x, y } in the map's pixels, or null when it is far outside the view.
flyTo(center, zoom?)Eases the map to a place.
fitBounds(places, padding?)Eases the map so every place is in view.
zoom, maxZoomThe current (fractional) zoom and the limit.

Globe

MapGlobe is a 3D dotted globe for places in the world as a whole: offices, customers, routes. It is a canvas with an orthographic projection and a small Natural Earth land mask, so it adds no dependency. It spins on its own, can be dragged, pauses offscreen, and stays still under reduced motion. It has no background of its own: it takes the colour of whatever it sits on.

Globe variants

land dots the continents; ocean dots the sea and leaves the land dark. Atmosphere, markers and arcs are each optional (Figma: Show atmosphere, Show markers, Show arcs).

land
ocean

Globe markers: hover and select

Markers have Default, Hover (a soft halo), Selected (a larger halo) and Active (a pulse) states. Hovering one stops the spin so it can be clicked, and a marker with a label shows its name in a small card while hovered or selected. Labelled markers are also reachable with Tab.

Selected: London

Globe in dark

It follows the page's theme like everything else. theme="dark" draws light dots for a globe that sits on a dark surface inside a light page.

Interaction

InputWhat happens
DragPans, with a little inertia after a quick flick.
Scroll or pinchZooms at the pointer.
Double-clickZooms in one level at the pointer.
Arrow keys (map focused)Pan.
+ and - (map focused)Zoom in and out.
Click a markerFires its onClick. Cluster markers made by MapClusterLayer zoom to their places.
Globe: hover a markerIts halo and name appear and the spin stops, so it can be clicked.

Accessibility

  • The map is a labelled region you can focus: arrow keys pan and + / - zoom, and the zoom buttons are real buttons with focus rings.
  • Give every MapMarker a label. Markers with onClick are buttons (aria-pressed follows selected); the rest are images with that name.
  • Popups are dialogs: Escape closes them when they have onClose.
  • Reduced motion turns off pan inertia, the zoom easing, glide and the globe's spin.
  • A map is hard to use without a pointer, so offer the places in a list or table next to it. The globe is decorative unless it has onMarkerClick: then labelled markers get a hidden button each.

Map props

PropTypeDefaultDescription
center[lat, lng][51.505, -0.09]Where the map starts. Changing it later flies the map there.
zoomnumber12Starting zoom, from 0 (the world) to maxZoom. Changing it later animates to it.
minZoom / maxZoomnumber2 / 18Zoom limits. The zoom buttons disable at the limits.
onViewChange(view) => voidCalled as the user pans or zooms, with { center, zoom }.
tileUrlstringOpenStreetMapTile template with {z}, {x} and {y}.
tileUrlDarkstringTiles to use in dark mode. Without it the light tiles are darkened with --map-tile-filter.
attributionReactNode | nullOpenStreetMap creditThe credit your tile provider requires. null hides it, only if your provider allows.
theme'auto' | 'dark''auto'auto follows the surrounding .dark class. dark makes this map dark wherever it sits.
controlsbooleantrueThe zoom buttons (Figma: Show controls).
locatebooleanfalseThe locate button, which uses the browser's geolocation.
onLocate / onLocateError(position) => void / (error) => voidAfter the locate button finds the user, or fails (denied, unavailable, unsupported).
heightnumber | string400px or any CSS length. The map fills its container's width.
aria-labelstring'Map'Accessible name of the map region.
childrenReactNodeMapMarker, MapPopup, MapClusterLayer, or your own overlays.

MapMarker props

PropTypeDefaultDescription
position[lat, lng]Where it is. Required.
variant'pin' | 'dot' | 'cluster''pin'A teardrop pin anchored at its tip, a 16px dot, or a 40px count.
countnumberThe number on a cluster.
selectedbooleanThe larger, haloed state.
glidenumberMilliseconds to slide to a new position instead of jumping. Off by default; reduced motion turns it off.
labelstringAccessible name, e.g. the place's name.
onClick(event) => voidMakes it a button. Without it the marker is a plain image.
hoveredbooleanPins the hover look, for documentation and visual tests. Real hover works on its own.

MapPopup props

PropTypeDefaultDescription
position[lat, lng]The place its pointer points at. Required.
title / descriptionReactNodeThe heading and the secondary line.
onClose() => voidShows a close button. Escape closes it too.
pointerbooleantrueThe notch under the card (Figma: Show pointer).
offsetnumber48Gap between the place and the pointer tip, in px.
glidenumberUse the same value as the marker it follows.
childrenReactNodeExtra content under the description: actions, a thumbnail.

MapClusterLayer props

PropTypeDefaultDescription
points{ id, position }[]Your places. Required.
renderPoint(point) => ReactNodeDraws one unclustered place, usually a MapMarker. Required.
radiusnumber56Grid size in px. Larger groups more places together.
clusterMaxZoomnumber16From this zoom up nothing clusters.
onClusterClick(points) => voidCalled after a cluster is clicked. The map has already zoomed to its places.

MapGlobe props

PropTypeDefaultDescription
variant'land' | 'ocean''land'Which part gets the bright dots.
center[lat, lng][15, 90]The place facing the viewer at first.
markersMapGlobeMarker[]{ position, label?, active?, selected?, hovered? }. active pulses; selected has the larger halo; label shows in a card while hovered or selected.
arcs{ from, to }[]Routes between two places, drawn over the sphere with a travelling pulse.
autoRotatebooleantrueSpins on its own. Off under reduced motion.
rotationSpeednumber6Degrees per second.
draggablebooleantrueDrag to spin, with inertia.
atmospherebooleantrueThe soft halo past the edge (Figma: Show atmosphere).
dotSpacingnumber1.3Degrees between dots. Smaller is denser and costs more to draw.
theme'auto' | 'dark''auto'dark draws light dots, for a globe on a dark surface inside a light page.
onMarkerClick(index, marker) => voidA marker was clicked or activated from the keyboard. Labelled markers get a hidden button, so Tab reaches them.
onMarkerHover(index | null) => voidThe pointer or keyboard focus moved onto a marker, or off all of them.
aria-labelstring'Globe'Accessible description.

Why it works this way

Why

  • There is no map library underneath. Tiles are plain images on a Web Mercator grid, so it adds nothing to your bundle, and every marker, control and popup is drawn with the same tokens as the rest of the system, in light, dark and glass.
  • Places are given as latitude and longitude and the markers follow the map. For live data, glide slides them to each new position; for many places, MapClusterLayer groups them and draws only what is in view.

Don't

  • Don't ship the default OpenStreetMap tiles to heavy production traffic. Give tileUrl your own provider (and its attribution).
  • Don't map thousands of MapMarkers yourself. Use MapClusterLayer.
  • Don't expect routing, geocoding or place search. It draws a map; bring your own data and services.
  • Don't rely on the map alone. It is hard to use without a pointer, so show the places in a list or table beside it.