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.

SoMa loft
$220 / night
Mission flat
$165 / night
Hayes Valley studio
$140 / night
Marina view
$285 / night

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

ProviderSDK (peer dep)RequiresDefault?
maplibremaplibre-gl— (Grade demo key on gradeui.com)
Yes
mapboxmapbox-glaccessTokenNo
google@googlemaps/js-api-loaderapiKeyNo

Map props

PropTypeDefaultDescription
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.
zoomnumberRequired. 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.
interactivebooleantrueSet false to disable pan / zoom / rotate (static display).
hoveredIdstring | 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.
tilerKeystringGrade demo keyprovider="maplibre" only. Required off gradeui.com / localhost; the bundled demo key is referrer-locked.
accessTokenstringprovider="mapbox" only. Required.
apiKeystringprovider="google" only. Required.

MapMarker props

PropTypeDefaultDescription
idstringRequired. 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.