Map

Vector map whose basemap is drawn from your theme tokens.

A vector map whose basemap is built 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.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.
  • 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 — See props below.
  • 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}>
      <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]}
  />
</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.

<Map center={[10, 50]} zoom={3}>
  <Map.Cluster data={sightings} radius={60} onPress={inspect} />
</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

Analytics map

Sessions by country, over a basemap dropped for the data. The shading and the cards above it come from the same tokens, so the map is part of the dashboard rather than an image pasted into it.

<Map blank bounds={[-11, 35, 31, 63]}>
  <Map.GeoJSON
    data={countries}
    fill={['interpolate', ['linear'], ['get', 'value'], 0, muted, peak, primary]}
  />
</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 continuous field. It fades out past maxZoom on purpose: zoomed far enough in, every point is its own island and the blur says less than the points would.

<Map bounds={[-10, 38, 20, 56]}>
  <Map.Heatmap data={reports} weight="weight" radius={28} />
</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 blank 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 over a blank world. A marker is a React view, so the status dot is the same one the rest of the app uses.

<Map blank 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>

Analytics card

The small end of the range — a map as one element inside a card. interactive={false} because at this size a stray pan is an accident: the card scrolls, the map does not.

<Card className="overflow-hidden">
  <LineChart data={week} xDataKey="day" aspectRatio={3}>
    <LineChart.Area dataKey="value" />
    <LineChart.Line dataKey="value" />
  </LineChart>
  <View className="h-44">
    <Map blank interactive={false} bounds={[-11, 35, 31, 63]}>
      <Map.GeoJSON data={countries} fill={ramp} />
    </Map>
  </View>
</Card>

Variants

position

  • top-left
  • top-right
  • bottom-left
  • bottom-right (default)
<Map position="top-left">…</Map>
<Map position="top-right">…</Map>
<Map position="bottom-left">…</Map>
<Map position="bottom-right">…</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.
sourceBasemapSourceCARTO_SOURCEWhere 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) => voidFires when the map is pressed somewhere that is not a feature.
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.
classNamestring

Map.Label

PropTypeDefaultDescription
classNamestring
side'top' | 'bottom'Which side of the marker the label sits on.

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.
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.
idstring'cluster'

Map.Heatmap

PropTypeDefaultDescription
dataunknownPoint features to spread.
weightstringFeature property to weight each point by. Unweighted when omitted.
radiusnumber24Spread of a single point, in points. Larger blurs more.
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.
idstring'heatmap'

Map.UserLocation

PropTypeDefaultDescription
headingbooleanShow which way the device is facing, not just where it 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.

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.

On this page