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{ "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 mapUsage
<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.filltakes 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-lefttop-rightbottom-leftbottom-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
| Prop | Type | Default | Description |
|---|---|---|---|
center | LngLat | — | Initial centre, [longitude, latitude]. |
zoom | number | 2 | Initial zoom. 0 is the whole world; 18 is a building. |
bearing | number | — | Initial bearing in degrees, clockwise from north. |
pitch | number | — | Initial tilt in degrees. 0 looks straight down. |
bounds | LngLatBounds | — | Frame these bounds instead of centring — [west, south, east, north]. Wins over center and zoom when both are given. |
blank | boolean | false | Drop 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. |
source | BasemapSource | CARTO_SOURCE | Where the vector tiles come from. Defaults to CARTO, which is free for non-commercial use and licensed for everything else. |
mapStyle | string | StyleSpecification | — | Use this style wholesale instead of building one from tokens. The escape hatch for a map that has to match something outside the app. |
rotatable | boolean | false | Let the map rotate and tilt. Off by default — most maps only pan and zoom. |
interactive | boolean | true | Turn off panning and zooming, for a map that is an illustration. |
onViewStateChange | (state: ViewState) => void | — | Fires continuously while the map moves. |
onPress | (lngLat: LngLat) => void | — | Fires when the map is pressed somewhere that is not a feature. |
onReady | () => void | — | Fires once the style has loaded and the first frame is drawn. |
Map.Marker
| Prop | Type | Default | Description |
|---|---|---|---|
lngLat | LngLat | — | Where 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 | () => void | — | Pressing the marker. Adds a button role when given. |
className | string | — |
Map.Label
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
side | 'top' | 'bottom' | — | Which side of the marker the label sits on. |
Map.Popup
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | |
title | string | — | Heading above the content. Strings are wrapped for you. |
lngLat | LngLat | — | Anchor to this coordinate instead of to an enclosing marker. Required when the popup is not inside one. |
Map.Controls
| Prop | Type | Default | Description |
|---|---|---|---|
position | MapControlsPosition | 'bottom-right' | Which corner the stack sits in. |
zoom | boolean | true | Zoom in and out. On by default — it is the one control a map always needs. |
locate | boolean | false | Recentre on the device's location. Needs a location permission. |
compass | boolean | false | Reset bearing and pitch to north and flat. |
className | string | — | |
onLocate | (lngLat: LngLat) => void | — | Called with the located coordinate, so a caller can react to it. |
Map.Route
| Prop | Type | Default | Description |
|---|---|---|---|
coordinates | LngLat[] | — | The path, in order. |
color | string | — | Defaults to the primary token. |
width | number | 3 | Line thickness in points. |
dashed | boolean | false | Draw it dashed — for a leg that is planned rather than travelled. |
opacity | number | 1 | 0 is invisible, 1 is solid. |
id | string | 'route' |
Map.Arc
| Prop | Type | Default | Description |
|---|---|---|---|
from | LngLat | — | Where the arc starts. |
to | LngLat | — | Where it ends. |
curvature | number | 0.2 | How far it bows. 0 is a straight line; 0.2 is the default lift. |
color | string | — | |
width | number | 2 | |
opacity | number | 0.9 | |
id | string | 'arc' |
Map.GeoJSON
| Prop | Type | Default | Description |
|---|---|---|---|
data | unknown | — | A Feature, FeatureCollection, or the URL of one. |
fill | string | unknown[] | — | Fill colour for polygons. A style expression works here too. |
stroke | string | unknown[] | — | Outline colour. Defaults to the border token. |
strokeWidth | number | 1 | Outline thickness. |
fillOpacity | number | 0.7 | 0 is invisible, 1 is solid. |
onPress | (feature: unknown) => void | — | Fires with the pressed feature. |
id | string | 'geojson' |
Map.Cluster
| Prop | Type | Default | Description |
|---|---|---|---|
data | unknown | — | Point features to cluster. |
color | string | — | Defaults to the primary token. |
textColor | string | — | Text colour inside a cluster bubble. |
radius | number | 50 | How close two points have to be, in points, to merge. |
maxZoom | number | 14 | Above this zoom every point stands alone. |
onPress | (feature: unknown) => void | — | Fires with the pressed cluster or point. |
id | string | 'cluster' |
Map.Heatmap
| Prop | Type | Default | Description |
|---|---|---|---|
data | unknown | — | Point features to spread. |
weight | string | — | Feature property to weight each point by. Unweighted when omitted. |
radius | number | 24 | Spread of a single point, in points. Larger blurs more. |
intensity | number | 1 | Overall strength. Raise it when the data is sparse. |
opacity | number | 0.85 | 0 is invisible, 1 is solid. |
maxZoom | number | 15 | Above this zoom the layer fades out — see the note on the component. |
id | string | 'heatmap' |
Map.UserLocation
| Prop | Type | Default | Description |
|---|---|---|---|
heading | boolean | — | Show 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.