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).
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.
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.
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.
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.
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.
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.
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>;
}| Member | What 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, maxZoom | The 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).
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
| Input | What happens |
|---|---|
| Drag | Pans, with a little inertia after a quick flick. |
| Scroll or pinch | Zooms at the pointer. |
| Double-click | Zooms in one level at the pointer. |
| Arrow keys (map focused) | Pan. |
| + and - (map focused) | Zoom in and out. |
| Click a marker | Fires its onClick. Cluster markers made by MapClusterLayer zoom to their places. |
| Globe: hover a marker | Its 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
MapMarkeralabel. Markers withonClickare buttons (aria-pressedfollowsselected); 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,
glideand 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
| Prop | Type | Default | Description |
|---|---|---|---|
| center | [lat, lng] | [51.505, -0.09] | Where the map starts. Changing it later flies the map there. |
| zoom | number | 12 | Starting zoom, from 0 (the world) to maxZoom. Changing it later animates to it. |
| minZoom / maxZoom | number | 2 / 18 | Zoom limits. The zoom buttons disable at the limits. |
| onViewChange | (view) => void | Called as the user pans or zooms, with { center, zoom }. | |
| tileUrl | string | OpenStreetMap | Tile template with {z}, {x} and {y}. |
| tileUrlDark | string | Tiles to use in dark mode. Without it the light tiles are darkened with --map-tile-filter. | |
| attribution | ReactNode | null | OpenStreetMap credit | The 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. |
| controls | boolean | true | The zoom buttons (Figma: Show controls). |
| locate | boolean | false | The locate button, which uses the browser's geolocation. |
| onLocate / onLocateError | (position) => void / (error) => void | After the locate button finds the user, or fails (denied, unavailable, unsupported). | |
| height | number | string | 400 | px or any CSS length. The map fills its container's width. |
| aria-label | string | 'Map' | Accessible name of the map region. |
| children | ReactNode | MapMarker, MapPopup, MapClusterLayer, or your own overlays. |
MapMarker props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| count | number | The number on a cluster. | |
| selected | boolean | The larger, haloed state. | |
| glide | number | Milliseconds to slide to a new position instead of jumping. Off by default; reduced motion turns it off. | |
| label | string | Accessible name, e.g. the place's name. | |
| onClick | (event) => void | Makes it a button. Without it the marker is a plain image. | |
| hovered | boolean | Pins the hover look, for documentation and visual tests. Real hover works on its own. |
MapPopup props
| Prop | Type | Default | Description |
|---|---|---|---|
| position | [lat, lng] | The place its pointer points at. Required. | |
| title / description | ReactNode | The heading and the secondary line. | |
| onClose | () => void | Shows a close button. Escape closes it too. | |
| pointer | boolean | true | The notch under the card (Figma: Show pointer). |
| offset | number | 48 | Gap between the place and the pointer tip, in px. |
| glide | number | Use the same value as the marker it follows. | |
| children | ReactNode | Extra content under the description: actions, a thumbnail. |
MapClusterLayer props
| Prop | Type | Default | Description |
|---|---|---|---|
| points | { id, position }[] | Your places. Required. | |
| renderPoint | (point) => ReactNode | Draws one unclustered place, usually a MapMarker. Required. | |
| radius | number | 56 | Grid size in px. Larger groups more places together. |
| clusterMaxZoom | number | 16 | From this zoom up nothing clusters. |
| onClusterClick | (points) => void | Called after a cluster is clicked. The map has already zoomed to its places. |
MapGlobe props
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | 'land' | 'ocean' | 'land' | Which part gets the bright dots. |
| center | [lat, lng] | [15, 90] | The place facing the viewer at first. |
| markers | MapGlobeMarker[] | { 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. | |
| autoRotate | boolean | true | Spins on its own. Off under reduced motion. |
| rotationSpeed | number | 6 | Degrees per second. |
| draggable | boolean | true | Drag to spin, with inertia. |
| atmosphere | boolean | true | The soft halo past the edge (Figma: Show atmosphere). |
| dotSpacing | number | 1.3 | Degrees 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) => void | A marker was clicked or activated from the keyboard. Labelled markers get a hidden button, so Tab reaches them. | |
| onMarkerHover | (index | null) => void | The pointer or keyboard focus moved onto a marker, or off all of them. | |
| aria-label | string | '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.