Map
A provider-agnostic map primitive. Switch between MapLibre, Mapbox, and Google Maps with one prop. Markers are DOM-rendered so they inherit Grade tokens like every other component.
Installation
import { Map, MapMarker } from "@gradeui/ui"
// Then install ONE of these — only what you actually use.
// All three SDKs are optional peer deps:
pnpm add maplibre-gl // provider="maplibre" (default)
pnpm add mapbox-gl // provider="mapbox"
pnpm add @googlemaps/js-api-loader // provider="google"The default provider="maplibre" works zero-config on gradeui.com and localhost via a referrer-locked Grade-owned MapTiler key. To use it on any other domain, register a free MapTiler key and pass it via tilerKey.
Usage
Examples
List ↔ map two-way sync
The canonical pattern. Hovering a card highlights the marker; hovering a marker highlights the card. Wire it through the controlled hoveredId + onHoveredIdChange pair — do not reach for refs and imperative flyTo for simple hover sync.
Static, non-interactive map
Satellite
Provider swap — one prop
The component lazy-loads the matching adapter based on provider. Mapbox and Google demos require keys, so they're shown as code only here.
// Mapbox — same engine as MapLibre, paid hosted styles + tiles
<Map
provider="mapbox"
accessToken={process.env.NEXT_PUBLIC_MAPBOX_TOKEN!}
center={[-0.1276, 51.5074]}
zoom={11}
/>
// Google Maps — uses AdvancedMarkerElement under the hood
<Map
provider="google"
apiKey={process.env.NEXT_PUBLIC_GOOGLE_MAPS_KEY!}
center={[-0.1276, 51.5074]}
zoom={11}
/>Imperative ref — fly-to + escape hatch
const mapRef = useRef<MapHandle>(null);
<Button onClick={() => mapRef.current?.flyTo("listing-3", { zoom: 15 })}>
Focus listing 3
</Button>
<Map ref={mapRef} center={...} zoom={...}>
...
</Map>
// Escape hatch — reach for the provider-native instance for features
// the wrapper doesn't expose (3D extrusions, drawing tools, heatmaps).
const mapbox = mapRef.current?.instance as mapboxgl.Map;
mapbox.addLayer({ id: "3d-buildings", type: "fill-extrusion", ... });Provider matrix
| Provider | SDK (peer dep) | Requires | Default? |
|---|---|---|---|
maplibre | maplibre-gl | — (Grade demo key on gradeui.com) | Yes |
mapbox | mapbox-gl | accessToken | No |
google | @googlemaps/js-api-loader | apiKey | No |
Map props
| Prop | Type | Default | Description |
|---|---|---|---|
| provider | "maplibre" | "mapbox" | "google" | "maplibre" | Which map engine to load. Default MapLibre uses MapTiler tiles via a referrer-locked Grade demo key — works zero-config on gradeui.com / localhost. |
| center | [lng: number, lat: number] | — | Required. Map viewport center. Always [lng, lat] tuple — even Google's adapter normalises internally. |
| zoom | number | — | Required. 0 (whole world) — 22 (street level). |
| bounds | [Coords, Coords] | - | [southwest, northeast]. Takes precedence over center+zoom when set. |
| appearance | "light" | "dark" | "satellite" | "auto" | "auto" | "auto" follows GradeThemeProvider mode. Each provider ships curated default styles per appearance — token → style-spec generation is a v1 follow-up. |
| interactive | boolean | true | Set false to disable pan / zoom / rotate (static display). |
| hoveredId | string | null | - | Controlled. The matching MapMarker gets data-gds-state="hovered" automatically. Pair with onHoveredIdChange for two-way list↔map sync. |
| onHoveredIdChange | (id: string | null) => void | - | Fires when the user hovers / unhovers a MapMarker. |
| onLoad | (handle: MapHandle) => void | - | Called once after the map and its first style have loaded. The handle exposes flyTo, panTo, fitBounds, getCenter, getZoom, getBounds, instance. |
| onError | (error: MapError) => void | - | Surfaces SDK-missing, api-key-missing, provider-init-failed, style-load-failed, tile-load-failed. |
| tilerKey | string | Grade demo key | provider="maplibre" only. Required off gradeui.com / localhost; the bundled demo key is referrer-locked. |
| accessToken | string | — | provider="mapbox" only. Required. |
| apiKey | string | — | provider="google" only. Required. |
MapMarker props
| Prop | Type | Default | Description |
|---|---|---|---|
| id | string | — | Required. Used by MapHandle.flyTo(id) and the Map's hoveredId controlled prop to match the marker. |
| at | [lng: number, lat: number] | — | Required. Marker position. Same [lng, lat] tuple as center. |
| anchor | "center" | "bottom" | "bottom" | "bottom" puts the pin tip at the coord (default). "center" places the visual midpoint at the coord. |
| onClick | (e: { id, coords, native: MouseEvent }) => void | - | Fires on marker click. Receives the id, current coords, and the native DOM event. |
Accessibility
- Each provider includes its own ARIA roles and keyboard navigation; do not strip them.
- For static, decorative maps, pass
interactive={false}to remove pan/zoom controls. - Markers are DOM elements — give them meaningful content (text, an icon with a label, a Badge with a price) rather than empty divs.
- If you build a list ↔ map sync, ensure the list items are keyboard-focusable and dispatch hover state on focus too, not only on mouseenter.
Sidecar
The Markdown sidecar Studio (and the Grade MCP server, when it ships) reads to understand this component — frontmatter, when- to-use guidance, and canonical examples. Authored once at packages/ui/components/ui/map.md and shipped inside the published @gradeui/ui tarball.
---
name: Map
import: "@gradeui/ui"
subcomponents: [MapMarker]
aliases: [map, maps, mapbox, maplibre, google maps, geo, location, latlng, coordinates, marker, pin, airbnb, listings, fleet, real estate, logistics, map view, mapkit, mapview, react native maps, rn maps]
props:
- provider — "maplibre" (default, free, no key) | "mapbox" (needs accessToken) | "google" (needs apiKey). Switching is one prop change.
- center — `[lng, lat]` tuple. ALWAYS lng first. Required.
- zoom — number, 0–22. Required.
- bounds — `[[swLng, swLat], [neLng, neLat]]`. When set, takes precedence over center+zoom.
- appearance — "light" | "dark" | "satellite" | "auto" (default "auto", follows GradeThemeProvider mode).
- hoveredId — controlled string id, pairs with onHoveredIdChange. The matching MapMarker gets `data-gds-state="hovered"` automatically. This is how you build list ↔ map two-way sync.
- interactive — false freezes pan/zoom, useful for static cards.
- onLoad(handle) / onError(error) — handle exposes flyTo, panTo, fitBounds, getCenter, getZoom, getBounds, instance.
- tilerKey (maplibre) — only needed off `gradeui.com`/`localhost`. Default key is referrer-locked.
- accessToken (mapbox), apiKey (google) — required for those providers.
when_to_use: Any layout that needs a real map — listings (real estate, Airbnb-style), fleet/logistics dashboards, store locators, anywhere a user picks a location from a viewport. Reach for the controlled `hoveredId` prop when a sibling list and the map need to highlight each other.
composes_with: [Card (as marker content), Badge, Avatar, Button, Row, Stack, Skeleton]
---
Default — zero config, MapLibre + MapTiler demo tiles. Works on `gradeui.com` and `localhost` with no setup:
```jsx
<Map center={[-122.42, 37.78]} zoom={12}>
<MapMarker id="hq" at={[-122.42, 37.78]}>
<Badge>HQ</Badge>
</MapMarker>
</Map>
```
Two-way list ↔ map hover sync — the canonical pattern. ALWAYS use the controlled `hoveredId` prop, do NOT call `mapRef.current.flyTo` on every list-item hover yourself:
```jsx
const [hoveredId, setHoveredId] = useState(null);
<Row>
<Stack>
{listings.map(l => (
<Card
key={l.id}
onMouseEnter={() => setHoveredId(l.id)}
onMouseLeave={() => setHoveredId(null)}
>
<CardHeader><CardTitle>{l.title}</CardTitle></CardHeader>
<CardContent>${l.price}/night</CardContent>
</Card>
))}
</Stack>
<Map
center={[-122.42, 37.78]}
zoom={12}
hoveredId={hoveredId}
onHoveredIdChange={setHoveredId}
>
{listings.map(l => (
<MapMarker key={l.id} id={l.id} at={l.coords}>
<Badge>${l.price}</Badge>
</MapMarker>
))}
</Map>
</Row>
```
Provider swap — one line:
```jsx
<Map provider="mapbox" accessToken={env.MAPBOX_TOKEN} center={[-0.1, 51.5]} zoom={11} />
<Map provider="google" apiKey={env.GOOGLE_MAPS_KEY} center={[-0.1, 51.5]} zoom={11} />
```
ANTI-PATTERNS — don't do these:
- DO NOT pass `{ lat, lng }` objects. Coordinates are ALWAYS `[lng, lat]` tuples. Google's adapter handles the object conversion internally.
- DO NOT hand-roll an iframe with a Google Maps embed URL. Use `<Map provider="google" apiKey={...}>`.
- DO NOT use `useRef` + `mapRef.current.flyTo(id)` on list-hover when `hoveredId` already does it controlled.
- DO NOT call `setStyle` or reach for `mapboxgl.Marker` directly — use `appearance` and `<MapMarker>`. The escape hatch (`mapRef.current.instance`) is for things the wrapper genuinely doesn't expose (3D extrusions, drawing tools, heatmaps).
- DO NOT render >500 markers without clustering. The component warns in dev. For larger datasets, drop to `.instance` and use the provider's clustering layer.
Markers are DOM — children inherit `--rds-*` tokens. Drop a `<Card>`, `<Badge>`, `<Avatar>`, or anything else inside `<MapMarker>` and it themes correctly.