Map

Vector map whose basemap is drawn from your theme tokens.

Alpha — the API is still moving. Expect it to change in a minor release.

A vector map whose basemap is drawn from your theme tokens rather than shipped as a finished style.

A hosted style gives you two maps, a light one and a dark one. PanelUI has six themes, and a map that stays grey while the rest of the screen turns green is the one rectangle on the page that visibly does not belong to it. The tiles still come from a provider — that is the part worth paying for — but every colour in the style is a token your theme already resolved, so a new theme gets a matching basemap without anyone drawing one.

Map needs a development build

The renderer is native code and is not part of the Expo SDK, so Map cannot run in Expo Go. Install @maplibre/maplibre-react-native, add its config plugin to your app config, and rebuild:

npx expo install @maplibre/maplibre-react-native expo-dev-client
npx expo prebuild --clean
npx expo run:ios   # or: eas build --profile development --platform ios
app.json
{ "expo": { "plugins": ["@maplibre/maplibre-react-native"] } }

Without it Map renders a message saying so rather than throwing, so a screen that embeds one still loads. Check hasMapLibre before routing somewhere whose whole content is a map.

Installation

Map ships with the library — no separate install.

import { Map, Text, type LngLat, hasMapLibre } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add map

Usage

<Map center={[-0.118, 51.509]} zoom={11}>
  <Map.Marker lngLat={[-0.118, 51.509]}>
    <Map.Popup title="Charing Cross">
      <Text size="xs" muted>The point every distance to London is measured from.</Text>
    </Map.Popup>
  </Map.Marker>
  <Map.Controls locate />
</Map>

Composition

<Map>
  <Map.Marker lngLat={[…]}>
    <Map.Label>…</Map.Label>
    <Map.Popup>…</Map.Popup>
  </Map.Marker>
  <Map.Route coordinates={[…]} />
  <Map.Arc from={[…]} to={[…]} />
  <Map.GeoJSON data={…} />
  <Map.Cluster data={…} />
  <Map.Heatmap data={…} />
  <Map.UserLocation />
  <Map.Controls />
</Map>
  • Map.Marker — A point drawn as React views, so it can hold anything the rest of the library can draw.
  • Map.Label — A caption pinned to a marker and always visible, unlike a popup. size and tone are for maps carrying a lot of them at once.
  • Map.Popup — A card anchored to a point — to the marker it sits inside, or to a coordinate of its own.
  • Map.Controls — Zoom, compass and locate, as themed views rather than the renderer's own ornaments.
  • Map.Route — A path across the map, drawn as a style layer so its cost does not grow with its length.
  • Map.Arc — A curved connection between two points, bowed so arcs sharing an endpoint stay tellable apart.
  • Map.GeoJSON — Arbitrary geography as a themed layer. fill takes a style expression, which is what makes a choropleth one layer instead of one per bucket.
  • Map.Cluster — Dense points merged as they get too close to tell apart — the layer to reach for past a few dozen.
  • Map.Heatmap — Point density as a continuous field — the layer for where, once the points are too many to count. points hands over to the records themselves as it fades out past maxZoom, so no zoom level shows neither.
  • Map.UserLocation — The device's own position, drawn by the renderer.

Examples

Markers and popups

A popup inside a marker anchors to it and opens when it is pressed. Given a lngLat of its own it stands alone at that coordinate instead — the same component either way, because the difference is where it hangs rather than what it is.

<Map center={[-74.006, 40.713]} zoom={11}>
  {offices.map((office) => (
    <Map.Marker
      key={office.id}
      lngLat={office.lngLat}
      accessibilityLabel={office.name}
      accessibilityHint="Show office details"
    >
      <Map.Popup title={office.name}>
        <Text size="xs" muted>{office.headcount} people</Text>
      </Map.Popup>
    </Map.Marker>
  ))}
</Map>

A blank canvas for data

blank drops the basemap and keeps only the ground colour, for data that carries its own geography. Street detail under a choropleth is noise rather than context.

<Map blank bounds={[-125, 24, -66, 50]}>
  <Map.GeoJSON
    data={states}
    fill={['interpolate', ['linear'], ['get', 'share'], 0, muted, 1, primary]}
    accessibility={(feature) => ({
      label: `${feature.properties.name}: ${feature.properties.share}%`,
    })}
  />
</Map>

Clustering dense points

Map.Marker mounts a React view each, which a thousand points cannot afford — and a thousand overlapping pins would be unreadable even if it could. Map.Cluster does it in a style layer, merging points as they get too close to tell apart.

accessibility reads the same inline Feature or FeatureCollection passed to the layer. It creates a clipped screen-reader list, and activating an entry calls the layer's onPress with that exact feature. The rendered map geometry stays decorative. Resolve URL data before passing it when the layer needs a nonvisual list; PanelUI does not keep a second feature copy that can drift from the map.

<Map center={[10, 50]} zoom={3}>
  <Map.Cluster
    data={sightings}
    radius={60}
    onPress={inspect}
    accessibility={(feature) => ({
      label: feature.properties.species,
      hint: 'Inspect sighting',
      state: { selected: feature.id === selectedId },
    })}
  />
</Map>

Routes and arcs

A route follows the ground; an arc does not pretend to. The bow on an arc is not geography — it is there so two connections sharing an endpoint stay tellable apart, which a bundle of straight lines through one city does not.

<Map blank bounds={[-130, 20, 30, 60]}>
  <Map.Route id="leg" coordinates={delivered} />
  <Map.Route id="remaining" coordinates={planned} dashed opacity={0.6} />
  {links.map((link) => (
    <Map.Arc key={link.id} id={link.id} from={link.from} to={link.to} />
  ))}
</Map>

Bringing your own tiles

The tiles are the one part of the style that is somebody else's to license. source swaps the provider while keeping the token-built layers, so the map still follows your theme. The default is CARTO, which is free for non-commercial use and needs a licence for everything else.

<Map
  source={{
    url: 'https://example.com/tiles.json',
    glyphs: 'https://example.com/fonts/{fontstack}/{range}.pbf',
    fonts: ['Inter Regular'],
    attribution: '© OpenStreetMap contributors',
  }}
/>

Versions

Places

A street map that is the whole screen, with the search field, the locate control and the place card sitting over it. On a map every point of chrome is a point of geography you cannot see, so nothing here takes a strip of its own — including the way back, which lives in the search bar. The card appears only once something is selected, and the pin you picked is the only one carrying a name: four labels at once is a map with the names of everything on it and the shape of nothing.

const [selected, setSelected] = useState(null);
const map = useRef(null);

<Map ref={map} center={here} zoom={14}>
  {places.map((place) => (
    <Map.Marker
      key={place.id}
      lngLat={place.lngLat}
      onPress={() => {
        setSelected(place);
        map.current?.flyTo({ center: place.lngLat, zoom: 15 });
      }}
    >
      <Pin active={place.id === selected?.id} />
      {place.id === selected?.id ? (
        <Map.Label side="top" size="sm" tone="primary">{place.name}</Map.Label>
      ) : null}
    </Map.Marker>
  ))}

  {route ? <Map.Route coordinates={[here, selected.lngLat]} width={5} /> : null}
  <Map.Controls locate position="top-right" />
</Map>

Choropleth

One layer shaded by a style expression rather than one layer per bucket — which is what lets the metric switch without rebuilding the map.

<Map blank bounds={[-11, 35, 31, 63]}>
  <Map.GeoJSON
    data={europeFeatures(values)}
    fill={['interpolate', ['linear'], ['get', 'value'], 0, muted, peak, primary]}
    fillOpacity={0.9}
  />
  <Map.Controls position="top-right" />
</Map>

Heatmap

Density as a field, with the ramp passed in and the points it was made of fading in as the field fades out. The derived ramp is the base colour at rising opacity, which is right over empty ground; over streets and coastline the opacity alone stops being separable from what is underneath, and the hue has to carry part of the reading — which is what colors is for.

<Map bounds={[-10, 38, 20, 56]}>
  <Map.Heatmap
    data={reports}
    weight="weight"
    radius={28}
    colors={['#fff7bc', '#fee391', '#fec44f', '#fe9929', '#d7301f']}
    points
    maxZoom={9}
  />
</Map>

Delivery tracker

A driven leg and a planned one, told apart by dash rather than by colour — the two mean different things, and colour is already carrying the theme.

<Map center={[0.02, 51.545]} zoom={10.5}>
  <Map.Route id="done" coordinates={driven} width={4} />
  <Map.Route id="left" coordinates={planned} width={4} dashed opacity={0.5} />
</Map>

Store locator

A list and a map on one selection, so pressing either moves the other.

<Map center={selected.lngLat} zoom={13}>
  {stores.map((store) => (
    <Map.Marker
      key={store.id}
      lngLat={store.lngLat}
      onPress={() => setSelected(store)}
    >
      <View className={store.id === selected.id ? activePin : pin} />
    </Map.Marker>
  ))}
  <Map.Controls locate position="top-right" />
</Map>

Logistics network

Arcs between sites. The bow is not geography — it is there so two lanes sharing a hub stay tellable apart, which a bundle of straight lines through one city does not.

<Map bounds={[-12, 36, 26, 60]}>
  {lanes.map((lane) => (
    <Map.Arc
      key={lane.id}
      id={lane.id}
      from={byId[lane.from].lngLat}
      to={byId[lane.to].lngLat}
      curvature={0.18}
    />
  ))}
</Map>

Uptime monitor

Edge nodes coloured by state, spread across the world. A marker is a React view, so the status dot is the same one the rest of the app uses.

<Map center={[0, 25]} zoom={1.1}>
  {nodes.map((node) => (
    <Map.Marker key={node.id} lngLat={node.lngLat}>
      <View className="items-center">
        <View className={dotFor(node.state)} />
        <Map.Label>{node.id.toUpperCase()}</Map.Label>
      </View>
    </Map.Marker>
  ))}
</Map>

API Reference

Map

PropTypeDefaultDescription
centerLngLatInitial centre, [longitude, latitude].
zoomnumber2Initial zoom. 0 is the whole world; 18 is a building.
bearingnumberInitial bearing in degrees, clockwise from north.
pitchnumberInitial tilt in degrees. 0 looks straight down.
boundsLngLatBoundsFrame these bounds instead of centring — [west, south, east, north]. Wins over center and zoom when both are given.
blankbooleanfalseDrop the basemap and keep only the ground colour. For data that carries its own geography — a choropleth, an arc diagram — where streets underneath are noise rather than context.
sourceBasemapSourceWhere the vector tiles come from. Defaults to CARTO, which is free for non-commercial use and licensed for everything else.
mapStylestring | StyleSpecificationUse this style wholesale instead of building one from tokens. The escape hatch for a map that has to match something outside the app.
rotatablebooleanfalseLet the map rotate and tilt. Off by default — most maps only pan and zoom.
interactivebooleantrueTurn off panning and zooming, for a map that is an illustration.
onViewStateChange(state: ViewState) => voidFires continuously while the map moves.
onPress(lngLat: LngLat, point: PixelPoint) => voidFires when the map is pressed somewhere that is not a feature. The second argument is the same press in screen coordinates, for anchoring something of your own to where the finger landed.
onReady() => voidFires once the style has loaded and the first frame is drawn.

Map.Marker

PropTypeDefaultDescription
lngLatLngLatWhere the marker sits, [longitude, latitude].
anchor'center' | 'top' | 'bottom' | 'left' | 'right''center'Which part of the marker sits on the coordinate. A pin drawn above its point wants bottom; a dot centred on it wants the default.
onPress() => voidPressing the marker. Adds a button role when given.
accessibilityLabelstringExplicit spoken name. Other React Native accessibility props pass through too.
classNamestring

Map.Label

PropTypeDefaultDescription
classNamestring
side'top' | 'bottom''bottom'Which side of the marker the label sits on.
size'sm' | 'md''md'sm for a map carrying a lot of them, where the pills start to collide.
tone'default' | 'muted' | 'primary''default'How loud the label is. muted for codes and counts that support the map without being its subject; primary for the one place being pointed at.

Map.Popup

PropTypeDefaultDescription
classNamestring
titlestringHeading above the content. Strings are wrapped for you.
lngLatLngLatAnchor to this coordinate instead of to an enclosing marker. Required when the popup is not inside one.

Map.Controls

PropTypeDefaultDescription
positionMapControlsPosition'bottom-right'Which corner the stack sits in.
zoombooleantrueZoom in and out. On by default — it is the one control a map always needs.
locatebooleanfalseRecentre on the device's location. Needs a location permission.
compassbooleanfalseReset bearing and pitch to north and flat.
classNamestring
onLocate(lngLat: LngLat) => voidCalled with the located coordinate, so a caller can react to it.

Map.Route

PropTypeDefaultDescription
coordinatesLngLat[]The path, in order.
colorstringDefaults to the primary token.
widthnumber3Line thickness in points.
dashedbooleanfalseDraw it dashed — for a leg that is planned rather than travelled.
opacitynumber10 is invisible, 1 is solid.
idstring'route'

Map.Arc

PropTypeDefaultDescription
fromLngLatWhere the arc starts.
toLngLatWhere it ends.
curvaturenumber0.2How far it bows. 0 is a straight line; 0.2 is the default lift.
colorstring
widthnumber2
opacitynumber0.9
idstring'arc'

Map.GeoJSON

PropTypeDefaultDescription
dataunknownA Feature, FeatureCollection, or the URL of one.
fillstring | unknown[]Fill colour for polygons. A style expression works here too.
strokestring | unknown[]Outline colour. Defaults to the border token.
strokeWidthnumber1Outline thickness.
fillOpacitynumber0.70 is invisible, 1 is solid.
onPress(feature: unknown) => voidFires with the pressed feature.
accessibility(feature: unknown, index: number) => MapFeatureAccessibilityDescribes each inline GeoJSON feature for the synchronized nonvisual list.
idstring'geojson'

Map.Cluster

PropTypeDefaultDescription
dataunknownPoint features to cluster.
colorstringDefaults to the primary token.
textColorstringText colour inside a cluster bubble.
radiusnumber50How close two points have to be, in points, to merge.
maxZoomnumber14Above this zoom every point stands alone.
onPress(feature: unknown) => voidFires with the pressed cluster or point.
accessibility(feature: unknown, index: number) => MapFeatureAccessibilityDescribes each source point for the synchronized nonvisual list.
idstring'cluster'

Map.Heatmap

PropTypeDefaultDescription
dataunknownPoint features to spread.
weightstringFeature property to weight each point by. Unweighted when omitted.
colorstringBase colour of the field — a theme token by name, or a literal. The ramp is this colour at rising opacity, so density reads as more of the same thing rather than as a change of subject. Defaults to --color-chart-2, which is a saturated accent in every theme. It is deliberately not --color-chart-1: that is the series colour a chart is about, and every theme starts it on something close to the foreground — near-black in a light theme, near-white in a dark one — which over a basemap is a smudge rather than a measurement.
colorsstring[]Replace the derived ramp outright, coolest first. For the conventional heat ramp, where the hue carries the reading as well as the opacity — worth it when the field sits over varied terrain and one hue at five opacities stops being separable from what is underneath it. The first stop is drawn at the lowest density, the last at the highest. Density zero stays fully transparent either way.
radiusnumber24Spread of a single point, in points, at street zoom. Larger blurs more. The drawn radius shrinks as the map zooms out, so a point keeps covering roughly the same ground rather than the same screen area.
intensitynumber1Overall strength. Raise it when the data is sparse.
opacitynumber0.850 is invisible, 1 is solid.
maxZoomnumber15Above this zoom the layer fades out — see the note on the component.
pointsbooleanfalseDraw the points themselves as the field fades out, coloured from the same ramp by weight. Without them, zooming past maxZoom leaves an empty map: the layer gets out of the way, and nothing takes its place.
idstring'heatmap'

Map.UserLocation

PropTypeDefaultDescription
headingbooleanfalseShow which way the device is facing, not just where it is.
accuracybooleanfalseDraw the ring showing how confident the fix is.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Why the basemap is assembled rather than downloaded

A hosted style ships its colours baked in, which gives you exactly two maps. Everything here is built from the same tokens as the rest of the library, so moon, grass and anything you add later get a basemap that matches them without a designer redrawing one per theme.

The layer list is deliberately short. A full street style runs to ninety-odd layers separating tunnel casings from bridge casings across eleven zoom stops; almost none of that survives being recoloured down to five greys, and every layer is another thing to keep in step with the tokens. What is there is the set that still reads as a map at any zoom: ground, water, green space, buildings, roads, boundaries, and the labels that make them findable.

Pass mapStyle to skip all of it and use a style wholesale — the escape hatch for a map that has to match something outside the app.

Tile licensing

Map defaults to CARTO's street tiles, which are free for non-commercial use and require a licence from CARTO for commercial use. That is a decision about your project rather than about this component, so source takes any provider serving the OpenMapTiles schema.

Controls and anything else you draw over the map

Map.Controls — and any view of your own written as a child of Map — is drawn above the map rather than inside the renderer. The renderer's own view lays its children out itself, so a view handed to it arrives stretched to the full size of the map and covering it. Splitting them means position lands where it says it does, and a control group written to sit in a corner sits in that corner.

What that leaves for you: put layers — Map.Marker, Map.Route, Map.Arc, Map.GeoJSON, Map.Cluster, Map.Heatmap, Map.UserLocation — inside Map and they reach the renderer. Everything else inside Map floats over it, positioned by your own classes, with touches passing through wherever it is not drawing.

Markers or clusters

Map.Marker is a React view, which is what lets it hold an avatar, a chip, or anything else the library draws. That also means a marker costs what a view costs. Past a few dozen points use Map.Cluster, which draws in a style layer and merges points as they crowd — by the time markers become expensive they have also become unreadable, so the two limits arrive together.

Labels on a crowded map

Map.Label is out of the marker's layout flow, and deliberately so: a marker sits on its coordinate by the centre of its box, so a label in flow beneath the pin would drag that centre down and lift every pin off the place it marks. The pill overhangs the marker by a wide margin on both sides, which is what lets a long name stay centred over a small dot without the marker being anchored by the name instead.

What that does not solve is a map with a dozen of them. size="sm" tightens the pill, and tone="muted" drops the text to the muted colour — together they are the difference between a network map and a wall of chips. The rule of thumb: label at default only the places the map is about, and let everything else support at muted. tone="primary" inverts the pill for the single place being pointed at.

Public exports

Values: Map, useMap, hasMapLibre, CARTO_SOURCE

Types: MapProps, MapHandle, MapMarkerProps, MapLabelProps, MapPopupProps, MapControlsProps, MapControlsPosition, MapRouteProps, MapArcProps, MapGeoJSONProps, MapClusterProps, MapFeatureAccessibility, MapHeatmapProps, MapUserLocationProps, BasemapSource, BasemapTokens, LngLat, LngLatBounds, ViewState

On this page