How to put an interactive, theme-aware map on a page with Once UI's Map module: markers from data, clustering, popups, areas, and the props that keep it friendly on long pages.
From @once-ui-system/core 2.6.0, a map with markers, pins and popups is one import, with free tiles and no API key.
Why a map used to be a project
Putting a map on a page usually meant signing up for a tile provider, pasting a key into the client, and then fighting a second design system: markers, popups and controls that looked nothing like the rest of the app. For a listings page, a venue finder or a contact page, that is a lot of work for one rectangle.
What core ships now
@once-ui-system/core/map draws an interactive map on MapLibre GL with OpenFreeMap tiles. The data is OpenStreetMap, there is no key and no view limit. The basemap follows your light or dark theme and is tinted with the page background token, so it sits on the same surface as the page.maplibre-gl is an optional peer, so add it next to core. It only loads when a map actually renders.
npm i @once-ui-system/core maplibre-gl
The smallest map
A map is a Column, so it takes the usual layout props. Give it a height, a camera, and something to mark.
import { Map, MapMarker, type LngLat } from "@once-ui-system/core/map";
const BUDAPEST: LngLat = [19.0402, 47.4979];
export function OfficeMap() {
return (
<Map height={24} defaultViewport={{ center: BUDAPEST, zoom: 12 }} labels={{ map: "Our office" }}>
<MapMarker position={BUDAPEST} />
</Map>
);
}
Positions are [longitude, latitude], the order MapLibre and GeoJSON use. Copying coordinates from a search result often gives you latitude first, so check that first if your pin lands in the ocean.
Markers are React
MapMarker takes any content: a MapPin, an Avatar, a button. It stays real DOM inside your theme, so a link in a marker is reachable by Tab, and focusing one off screen pans it into view. MapPin is the marker Once UI draws for you: a pill with a label such as a price, or a dot when it has no children. With href it is a link, with onClick a button.
Many places from data
For a list of results, hand the data to MapMarkers and let bounds frame it.
bounds refits when the list changes, so a new set of search results is always in view. boundsMaxZoom (15 by default) stops a single result from filling the screen.
cluster merges overlapping markers into a count pill that zooms in when pressed. It is worked out per zoom level, so clusters hold still while you pan.
activeId lifts one marker above the rest, which is what you want when a row in a list beside the map is hovered.
MapPopup is a surface card you render while it is open, like a Dialog. Escape and the close button call onClose.
An area instead of a point
When you shouldn't show an exact address, or you want a delivery radius, draw a MapCircle. The radius is in metres and the colour is a token family, brand by default.
Small props worth knowing
Prop
What it does
`cooperativeGestures`
Scroll zooms only with Ctrl or ⌘, touch pans with two fingers
`interactive={false}`
A static map with no pan or zoom
`controls={false}`
Hides the zoom buttons
`mapStyle`
Your own basemap, or a `{ light, dark }` pair
Turn on cooperativeGestures for any map in the middle of a long page, so people scrolling past don't get stuck zooming. If you switch to a keyed tile provider, pass the key through transformRequest rather than baking it into a style URL.
Keyboard panning and zoom, reduced motion and a fallback message without WebGL are built in. Set labels.map to a real name, since it is the map's accessible name.
When you add click handlers
Map mounts on the client only, because WebGL has nothing to draw on the server. The smallest map above needs nothing extra, but as soon as you pass onClick, state or onViewportChange, put the map in a file that starts with "use client", as in the venue example.