Skip to content

Map and markers

map mounts a MapLibre map on the unmap gateway and shares it with every component placed inside it. Markers, popups and controls go inside it and follow your design tokens. Components covers installing, the two lanes and styling.

npx shadcn@latest registry add @unmap=https://registry.unmap.dev/r/{name}.json
npx shadcn@latest add @unmap/map

The React line registers the registry once per project, so later items can be named @unmap/<item>; shadcn-vue has no registry add and takes the item URL directly. Files land in components/ui/ beside your own; nothing is overwritten without asking.

Browse every item on registry.unmap.dev
ItemWhat it is
map-contextThe shared map store the other items depend on. Installed automatically.
map-themesNamed short theme presets (u00 and up) for the map's theme prop. Compose your own at unmap.dev/create.
mapThe map display component. Mounts a container, creates a MapLibre map on the unmap gateway, and shares it with its children. Restyles MapLibre's popups and controls onto your tokens.
marker-popupDeclarative markers, popups, hover tooltips and labels positioned on the map. Pins can be dragged.
map-controlsZoom, compass, locate, fullscreen, globe and 3D terrain buttons on your own ButtonGroup, Button and Tooltip. Pair with controls={false} on the map.

Markers and interaction

A <Marker> can be dragged, labelled and given a hover tooltip. draggable lets the reader move the pin and onDragEnd (Vue: @dragend) receives where it was dropped. The pin stays there, and lngLat moves it again whenever its coordinates change, so write the drop back into state if you keep one. To reject a drop, set lngLat to a different value: the same value is not a change and leaves the pin where it was dropped. <MarkerLabel> writes text beside the pin (position is right, left, top or bottom), <MarkerTooltip> shows a small popup while the pointer is over it, and <Popup trigger="hover"> opens a full popup the same way.

import { Map } from "@/components/ui/map";
import { Marker, MarkerLabel, MarkerTooltip, Popup } from "@/components/ui/marker-popup";
 
<Map apiKey={key} center={[-114.07, 51.05]} zoom={11} maxBounds="canada" minZoom={3} hash>
  <Marker lngLat={site} draggable onDragEnd={setSite}>
    <MarkerLabel>Site A</MarkerLabel>
    <MarkerTooltip>Drag to move</MarkerTooltip>
    <Popup>Details for site A</Popup>
  </Marker>
</Map>

Try it, then add it as a starting point:

npx @unmap/cli add map-markers-01

React projects. Installs components/unmap-map-markers.tsx; render <MapMarkers apiKey={key} />.

Open the example page

The same in Vue:

<script setup lang="ts">
import { UnmapMap } from "@/components/ui/map";
import { UnmapMarker, UnmapMarkerLabel, UnmapMarkerTooltip, UnmapPopup } from "@/components/ui/marker-popup";
 
const apiKey = import.meta.env.VITE_UNMAP_KEY!;
const site: [number, number] = [-114.07, 51.05];
function onDragEnd(lngLat: [number, number]) {
  console.log(lngLat);
}
</script>
 
<template>
  <UnmapMap :api-key="apiKey" :center="[-114.07, 51.05]" :zoom="11" max-bounds="canada" :min-zoom="3" hash>
    <UnmapMarker :lng-lat="site" draggable @dragend="onDragEnd">
      <UnmapMarkerLabel>Site A</UnmapMarkerLabel>
      <UnmapMarkerTooltip>Drag to move</UnmapMarkerTooltip>
      <UnmapPopup>Details for site A</UnmapPopup>
    </UnmapMarker>
  </UnmapMap>
</template>
  • Hover means the pointer over the pin or keyboard focus on it; a press does not count. Touch screens have no hover, so a tooltip never shows there, and a pin with a tooltip but no popup cannot take focus unless it is draggable (a default pin): keep anything a reader needs in a label or a popup. A hover popup still opens on a tap, and a click or Enter toggles it on any screen. It closes when the pointer or focus leaves the pin, so its content cannot be reached: keep it read-only, drop the close button (it defaults to off), and use the default click trigger for anything interactive. The tooltip follows the pin when it moves, steps aside while the pin's popup is open, and never shows beside a hover popup, which opens on the same hover.
  • interactive={false} (Vue: :interactive="false") turns off panning, zooming, rotating and the keyboard, for a map that is a picture. Pins, draggable ones included, popups and the zoom buttons keep working. Turning it back on respects anything the raw maplibre option switched off, such as dragRotate: false. cooperativeGestures (Vue: cooperative-gestures) asks for Ctrl or Cmd with the scroll wheel, and two fingers on a phone, so a map in a long page does not trap the scroll.
  • maxBounds keeps the camera inside [[west, south], [east, north]], or inside all of Canada with a margin when it is "canada". minZoom and maxZoom bound the zoom. Each of these falls back to the raw maplibre option when its prop is unset. A zoom limit MapLibre would reject (a minZoom below -2 or above the max zoom, a maxZoom below -2, or one that is not a finite number) is dropped with a console warning rather than throwing, and an inverted minZoom and maxZoom pair drops both. A maxZoom above 22 is kept as given. When a limit moves the camera, onViewportChange (Vue: @viewport-change) reports where it went.
  • hash keeps the camera in the page URL (#zoom/lat/lng), so a reload or a shared link opens the same view, and a position already in the URL wins over center and zoom. Give it a name (hash="map") to keep the rest of the hash, or to put two maps in one URL. Turning it off, or removing the map, takes the map's part out of the URL. A map recreated by a new apiKey starts from the props' camera.
  • Every one of these changes in place, without rebuilding the map. The tooltip's compact style comes with the map item. Tooltips carry the unmap-tooltip class and labels a data-unmap-marker-label attribute, as hooks for your own CSS.

Map controls

map-controls puts zoom, compass, locate, fullscreen, globe and 3D terrain buttons on the map, built on your ButtonGroup, Button and Tooltip, with icons from your icon library. Turn the map's own buttons off with controls={false} so the two sets do not stack in the same corner. The attribution stays: the OpenStreetMap licence requires it.

import { Map } from "@/components/ui/map";
import { MapControls } from "@/components/ui/map-controls";
 
<Map apiKey={key} style="outdoor" center={[-115.57, 51.18]} zoom={11} controls={false}>
  <MapControls position="top-right" locate fullscreen globe terrain />
</Map>
<script setup lang="ts">
import { UnmapMap } from "@/components/ui/map";
import { UnmapMapControls } from "@/components/ui/map-controls";
 
const apiKey = import.meta.env.VITE_UNMAP_KEY!;
</script>
 
<template>
  <UnmapMap :api-key="apiKey" map-style="outdoor" :center="[-115.57, 51.18]" :zoom="11" :controls="false">
    <UnmapMapControls position="top-right" locate fullscreen globe terrain />
  </UnmapMap>
</template>
  • Zoom and the compass are on by default; locate, fullscreen, globe and terrain are opt-in, and zoom={false} or compass={false} turns a default off. position takes any corner. Zoom in and zoom out are disabled at the map's zoom limits.
  • The compass needle follows the bearing, and a press turns the map back to north and flat.
  • locate asks the browser for the reader's position, flies there and marks it with a dot in your --primary. When the browser refuses, cannot find a position or has no location at all, an Alert under the buttons says so. Pass locateAlert={false} and handle onLocateError (Vue: @locate-error) to show your own message instead. onLocate (Vue: @locate) receives the position. Geolocation needs HTTPS.
  • fullscreen fills the screen with the map and its controls. Where the page cannot go fullscreen, such as an iframe without permission or Safari on iPhone, the map fills the window instead, and Escape leaves it. Anything that opens in a portal, such as these tooltips, the geocoder's suggestions or a select, stays outside the fullscreen map in both cases: native fullscreen shows only the map's element, and the map that fills the window covers the rest of the page at MapLibre's z-index of 99999. Every button keeps its accessible name. To bring a portal along, point its container (Reka UI: to) at the map's element, map.getContainer(), in your primitive.
  • globe switches between the flat map and the globe. The button shows what the map shows, so it agrees with the projection prop, and the choice survives a change of mode, style or theme.
  • terrain tilts the map over 3D terrain. It appears only on a style that carries elevation, such as outdoor; DEM extent follows the active release in /coverage.json. It stays on across a style change and comes back on the next style with elevation. Terrain loads elevation tiles, and each one is a request, like any tile.
  • lang="fr" names the buttons and writes the messages in French. Toggles carry aria-pressed, and every button has a name and a tooltip.