Map
freeMap wraps MapLibre with markers, popups, routes, arcs, and point clustering, themed to match the rest of the UI.
import Map from '@/components/composites/Map';
export default function UsageDemo() {
return (
<div className="h-80 w-full">
<Map center={[-122.4194, 37.7749]} zoom={11}>
<Map.Marker longitude={-122.3937} latitude={37.7955}>
<Map.MarkerContent />
<Map.MarkerPopup>
<div className="font-semibold text-foreground">
Ferry Building
</div>
<p className="text-muted-foreground">
Historic marketplace and transit hub on the Embarcadero.
</p>
</Map.MarkerPopup>
</Map.Marker>
<Map.Marker longitude={-122.4058} latitude={37.8024}>
<Map.MarkerContent />
<Map.MarkerPopup>
<div className="font-semibold text-foreground">Coit Tower</div>
<p className="text-muted-foreground">
Art Deco tower with panoramic views of the bay.
</p>
</Map.MarkerPopup>
</Map.Marker>
</Map>
</div>
);
}Installation
Add this component with the NateUI CLI.
npx nateui@latest add MapExamples
Static Preview
import Map from '@/components/composites/Map';
export default function StaticPreviewDemo() {
return (
<div className="h-36 w-full">
<Map
center={[-97.7431, 30.2672]}
zoom={14}
interactive={false}
attributionControl={false}
className="h-full min-h-0 shadow-none"
>
<Map.Marker longitude={-97.7431} latitude={30.2672}>
<Map.MarkerContent />
</Map.Marker>
</Map>
</div>
);
}Popups & Labels
import Map from '@/components/composites/Map';
export default function PopupsLabelsDemo() {
return (
<div className="h-80 w-full">
<Map center={[-122.42, 37.778]} zoom={13}>
<Map.Marker longitude={-122.43} latitude={37.78}>
<Map.MarkerContent />
<Map.MarkerLabel>HQ</Map.MarkerLabel>
</Map.Marker>
<Map.Marker longitude={-122.41} latitude={37.77}>
<Map.MarkerContent />
<Map.MarkerTooltip>Warehouse — 12 units in stock</Map.MarkerTooltip>
</Map.Marker>
<Map.Popup longitude={-122.4} latitude={37.785}>
<div className="font-semibold text-foreground">Zone closed</div>
<p className="text-muted-foreground">
Scheduled maintenance until Friday.
</p>
</Map.Popup>
</Map>
</div>
);
}Controls
import Map from '@/components/composites/Map';
export default function ControlsDemo() {
return (
<div className="h-80 w-full">
<Map center={[-122.3321, 47.6062]} zoom={11}>
<Map.Controls
position="top-right"
showZoom
showCompass
showLocate
showFullscreen
/>
</Map>
</div>
);
}Route
import Map from '@/components/composites/Map';
const routeCoordinates: [number, number][] = [
[-122.4194, 37.7749],
[-122.4094, 37.7799],
[-122.4008, 37.7852],
[-122.3937, 37.7955],
];
export default function RouteDemo() {
const [start, ...rest] = routeCoordinates;
const end = rest[rest.length - 1];
return (
<div className="h-80 w-full">
<Map center={[-122.406, 37.783]} zoom={10}>
<Map.Route coordinates={routeCoordinates} interactive width={4} />
<Map.Marker longitude={start[0]} latitude={start[1]}>
<Map.MarkerContent />
</Map.Marker>
<Map.Marker longitude={end[0]} latitude={end[1]}>
<Map.MarkerContent className="bg-success after:bg-success-foreground" />
</Map.Marker>
</Map>
</div>
);
}Arc
import Map from '@/components/composites/Map';
const hubs = {
sf: [-122.4194, 37.7749] as [number, number],
newYork: [-74.006, 40.7128] as [number, number],
london: [-0.1276, 51.5074] as [number, number],
tokyo: [139.6917, 35.6895] as [number, number],
sydney: [151.2093, -33.8688] as [number, number],
};
const arcData = [
{ id: 'sf-london', from: hubs.sf, to: hubs.london },
{ id: 'sf-tokyo', from: hubs.sf, to: hubs.tokyo },
{ id: 'sf-sydney', from: hubs.sf, to: hubs.sydney },
{ id: 'newyork-london', from: hubs.newYork, to: hubs.london },
];
export default function ArcDemo() {
return (
<div className="h-80 w-full">
<Map center={[0, 20]} zoom={1}>
<Map.Arc data={arcData} interactive />
</Map>
</div>
);
}Cluster Layer
import Map from '@/components/composites/Map';
const storeCoordinates: [number, number][] = [
[-122.42, 37.77], [-122.41, 37.78], [-122.44, 37.76],
[-122.39, 37.75], [-122.43, 37.8], [-122.4, 37.73],
[-118.24, 34.05], [-118.29, 34.02], [-118.2, 34.08],
[-118.33, 34.06], [-118.22, 34.01], [-118.27, 34.09],
[-117.16, 32.72], [-117.2, 32.75], [-117.12, 32.7],
[-117.18, 32.68], [-117.1, 32.74], [-117.22, 32.71],
[-121.49, 38.58], [-121.45, 38.55], [-121.52, 38.6],
[-121.47, 38.62], [-121.53, 38.56], [-121.44, 38.59],
];
const storeLocations: GeoJSON.FeatureCollection<GeoJSON.Point> = {
type: 'FeatureCollection',
features: storeCoordinates.map((coordinates, index) => ({
type: 'Feature',
properties: { storeId: index },
geometry: { type: 'Point', coordinates },
})),
};
export default function ClusterLayerDemo() {
return (
<div className="h-80 w-full">
<Map center={[-119.4179, 36.7783]} zoom={5}>
<Map.ClusterLayer data={storeLocations} />
</Map>
</div>
);
}Controlled Viewport
import { useState } from 'react';
import Segment from '@/components/ui/Segment';
import Map from '@/components/composites/Map';
const cities = [
{ label: 'New York', center: [-74.006, 40.7128] as [number, number] },
{ label: 'London', center: [-0.1276, 51.5074] as [number, number] },
{ label: 'Tokyo', center: [139.6917, 35.6895] as [number, number] },
{ label: 'Sydney', center: [151.2093, -33.8688] as [number, number] },
];
export default function ControlledViewportDemo() {
const [active, setActive] = useState(cities[0].label);
const [viewport, setViewport] = useState({
center: cities[0].center,
zoom: 11,
bearing: 0,
pitch: 0,
});
const handleChange = (label: string) => {
const city = cities.find((c) => c.label === label);
if (!city) return;
setActive(label);
setViewport({ center: city.center, zoom: 11, bearing: 0, pitch: 0 });
};
return (
<div className="w-full space-y-4">
<div className="flex justify-center">
<Segment value={active} onChange={handleChange}>
{cities.map((city) => (
<Segment.Item key={city.label} value={city.label}>
{city.label}
</Segment.Item>
))}
</Segment>
</div>
<div className="h-72">
<Map viewport={viewport} onViewportChange={setViewport} className="h-full" />
</div>
<div className="text-xs text-muted-foreground">
{viewport.center[1].toFixed(2)}, {viewport.center[0].toFixed(2)} ·
zoom {viewport.zoom.toFixed(1)}
</div>
</div>
);
}API
Map
| Prop | Description | Type | Default |
|---|---|---|---|
children | Markers, popups, routes, arcs, and cluster layers. | ReactNode | — |
theme | Tile style to use. | 'light' | 'dark' | app's ConfigProvider mode |
styles | Override the default tile style URL per theme. | { light?: MapStyleOption; dark?: MapStyleOption } | OpenFreeMap positron/dark |
viewport | Controlled camera. Pair with onViewportChange (see Controlled Viewport). | Partial<MapViewport> | — |
onViewportChange | Called on pan/zoom/rotate with the new camera state. | (viewport: MapViewport) => void | — |
loading | Shows a centered spinner overlay. | boolean | false |
ref | Exposes the underlying maplibregl.Map instance. | Ref<MapRef | null> | — |
className | Class names applied to the map's root element. | string | — |
center | Initial camera center (uncontrolled). | LngLatTuple | — |
zoom | Initial zoom level (uncontrolled). | number | — |
bearing | Initial map rotation, in degrees (uncontrolled). | number | 0 |
pitch | Initial camera tilt, in degrees (uncontrolled). | number | 0 |
interactive | Disables all pan/zoom/rotate gestures when false. | boolean | true |
attributionControl | Shows the map data attribution badge. | boolean | true |
...mapOptions | The rest of MapLibre's own MapOptions — see MapLibre's docs. | Omit<maplibregl.MapOptions, 'container' | 'style'> | — |
Map.Marker
| Prop | Description | Type | Default |
|---|---|---|---|
longitude | Marker position. | number | — |
latitude | Marker position. | number | — |
children | Marker visual and any attached Map.MarkerPopup / Map.MarkerTooltip / Map.MarkerLabel. | ReactNode | — |
onClick | Fired on marker click. | (event: MouseEvent) => void | — |
onMouseEnter / onMouseLeave | Fired on hover. | (event: MouseEvent) => void | — |
onDragStart / onDrag / onDragEnd | Fired while dragging, when draggable is set. | (lngLat: { lng: number; lat: number }) => void | — |
...markerOptions | The rest of MapLibre's own MarkerOptions (draggable, offset, rotation, anchor, color, …). | Omit<maplibregl.MarkerOptions, 'element'> | — |
Map.MarkerContent
The default pin visual — a small primary-colored dot. Not required; Map.Marker's children accepts any node.
| Prop | Description | Type | Default |
|---|---|---|---|
children | Content rendered inside the dot. | ReactNode | — |
className | Class names applied to the dot. | string | — |
Map.MarkerPopup
Click-to-open popup attached to a marker. Must be rendered inside Map.Marker.
| Prop | Description | Type | Default |
|---|---|---|---|
children | Popup content. | ReactNode | — |
closeButton | Shows a close button in the corner. | boolean | false |
className | Class names applied to the popup body. | string | — |
...popupOptions | The rest of MapLibre's own PopupOptions (offset, maxWidth, anchor, …). | Omit<maplibregl.PopupOptions, 'closeButton'> | — |
Map.MarkerTooltip
Hover-to-show tooltip attached to a marker. Must be rendered inside Map.Marker.
| Prop | Description | Type | Default |
|---|---|---|---|
children | Tooltip content. | ReactNode | — |
className | Class names applied to the tooltip body. | string | — |
...popupOptions | The rest of MapLibre's own PopupOptions, minus closeButton and closeOnClick. | Omit<maplibregl.PopupOptions, 'closeButton' | 'closeOnClick'> | — |
Map.MarkerLabel
Small always-visible caption positioned above or below a marker.
| Prop | Description | Type | Default |
|---|---|---|---|
children | Label content. | ReactNode | — |
position | Side of the marker the label sits on. | 'top' | 'bottom' | 'top' |
className | Class names applied to the label. | string | — |
Map.Popup
A standalone popup at its own coordinate, not attached to a marker.
| Prop | Description | Type | Default |
|---|---|---|---|
longitude | Popup position. | number | — |
latitude | Popup position. | number | — |
children | Popup content. | ReactNode | — |
closeButton | Shows a close button in the corner. | boolean | false |
onClose | Called when the popup closes. | () => void | — |
className | Class names applied to the popup body. | string | — |
...popupOptions | The rest of MapLibre's own PopupOptions, minus closeButton. | Omit<maplibregl.PopupOptions, 'closeButton'> | — |
Map.Controls
Floating zoom / compass / locate / fullscreen button cluster.
| Prop | Description | Type | Default |
|---|---|---|---|
position | Corner of the map the cluster docks to. | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'bottom-right' |
showZoom | Shows zoom in/out buttons. | boolean | true |
showCompass | Shows a button that resets bearing and pitch. | boolean | false |
showLocate | Shows a button that flies to the user's current location. | boolean | false |
showFullscreen | Shows a button that toggles fullscreen. | boolean | false |
onLocate | Called with the resolved coordinates after a successful locate. | (coords: { longitude: number; latitude: number }) => void | — |
className | Class names applied to the control cluster. | string | — |
Map.Route
A plain polyline between coordinates.
| Prop | Description | Type | Default |
|---|---|---|---|
coordinates | Line waypoints, in order. Needs at least 2 points. | LngLatTuple[] | — |
color | Line color. | string | theme --nui-primary |
width | Line width, in pixels. | number | 3 |
opacity | Line opacity. | number | 1 |
dashArray | Dash pattern as [dash, gap]. | [number, number] | — |
interactive | Attaches onClick / onMouseEnter / onMouseLeave. | boolean | false |
onClick / onMouseEnter / onMouseLeave | Fired when interactive is true. | () => void | — |
beforeId | Insert this layer before an existing layer id. | string | — |
Map.Arc
Curved lines between point pairs, for flight-path / network-style visualizations.
| Prop | Description | Type | Default |
|---|---|---|---|
data | Arc endpoints. | MapArcDatum[] | — |
curvature | How much the arc bows away from a straight line. | number | 0.2 |
samples | Points used to draw the curve — higher is smoother. | number | 64 |
paint | MapLibre line paint override. | LinePaint | theme --nui-primary |
layout | MapLibre line layout override. | LineLayout | — |
hoverPaint | Paint applied to an arc while hovered (interactive only). | LinePaint | — |
interactive | Attaches hover state and onClick / onHover. | boolean | false |
onClick / onHover | Fired when interactive is true. | (event: MapArcEvent | null) => void | — |
beforeId | Insert this layer before an existing layer id. | string | — |
Map.ClusterLayer
Groups nearby GeoJSON points into clusters that expand as you zoom in.
| Prop | Description | Type | Default |
|---|---|---|---|
data | Point features to cluster, or a URL to load them from. | string | GeoJSON.FeatureCollection<GeoJSON.Point> | — |
clusterMaxZoom | Zoom level above which points no longer cluster. | number | 14 |
clusterRadius | Cluster grouping radius, in pixels. | number | 50 |
clusterPaint | MapLibre paint override for cluster circles. | CirclePaint | theme --nui-primary |
clusterCountPaint | MapLibre paint override for the cluster count label. | SymbolPaint | — |
pointPaint | MapLibre paint override for individual (non-clustered) points. | CirclePaint | theme --nui-primary |
onClusterClick | Fired on cluster click. Also auto-zooms to expand the cluster. | (event: { clusterId: number; coordinates: LngLatTuple; originalEvent }) => void | — |
onPointClick | Fired on an individual point click. | (event: { feature; coordinates: LngLatTuple; originalEvent }) => void | — |
beforeId | Insert these layers before an existing layer id. | string | — |
useMap
Returns the shared map context. Must be called within Map.
function useMap(): {
map: maplibregl.Map | null;
isLoaded: boolean;
isStyleLoaded: boolean;
}Types
type MapViewport = {
center: [longitude: number, latitude: number];
zoom: number;
bearing: number;
pitch: number;
}
type MapStyleOption = string | maplibregl.StyleSpecification
type LngLatTuple = [longitude: number, latitude: number]
type MapArcDatum = {
id: string | number;
from: LngLatTuple;
to: LngLatTuple;
}
type MapArcEvent<T = MapArcDatum> = {
arc: T;
longitude: number;
latitude: number;
originalEvent: maplibregl.MapMouseEvent;
}LinePaint, LineLayout, CirclePaint, SymbolPaint, and SymbolLayout are MapLibre's own style-spec paint/layout types, re-exported as-is — see MapLibre's style spec rather than a NateUI-defined shape.