Map
A vector map drawn by MapLibre in your theme's colours, with buttons to zoom, point north, find the reader and fill the screen.
defaultCenter={[-73.9700, 40.7400]}defaultZoom={10.5}- Drag to pan, double-click to zoom in. Drag with the right button to turn it: the compass follows, and a click on it points north.
- Tab to the map: arrows move it, + and โ zoom, Shift with the arrows turns and tilts it.
- Move it, then copy the props that open a map on that view.
Field marks
- Import
import { Map, MapControls } from "cennge-ui/map";- Size
- 7.4 KB gzip for this import, its dependencies aside
- Pattern
- A
role="region"named by youraria-label; the canvas in it takes focus; controls are native buttons - Keyboard
- Arrow up Arrow down Arrow left Arrow right +โShift Arrow left
- State
- Uncontrolled: opens on
defaultCenteranddefaultZoom, ordefaultBounds;onViewChangereports each move - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Loads
- maplibre-gl 6, fetched when the first map mounts
- Base map
- OpenFreeMap: no key, no usage limits
- Tests
- Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: map
- Source
- src/map
When to use#
Use a Map when where something is matters: offices, stores, a route, numbers by area. Markers, popups, routes and data layers go inside it as children, and so do its controls.
maplibre-gl 6 is an optional peer dependency: npm install maplibre-gl in the apps that show a map. It is fetched when the first map mounts, so pages without a map never download it. The base map is OpenFreeMap's Positron recoloured with your tokens: land in the surfaces, water and parks touched with info and success, labels in the text colours. It follows the theme; the accent is left to what you draw on it.
Similar#
- A BarChart compares numbers by name; a map with data layers shows where they are. When the place does not matter, use the chart.
- The other map pages are its parts: markers and popups for places, routes and arcs for the ways between them.
Open on an area#
defaultBounds takes the south-west and north-east corners and picks the zoom that fits them on any screen; boundsPadding keeps room for the buttons. It wins over defaultCenter and defaultZoom.
import { Map, MapControls } from "cennge-ui/map";
export default function ManhattanMap() {
return (
<Map
aria-label="Manhattan"
// South-west and north-east corners: the Battery to Inwood.
defaultBounds={[
[-74.02, 40.7],
[-73.91, 40.875],
]}
// More room on the right, where the buttons are.
boundsPadding={{ top: 24, right: 64, bottom: 24, left: 24 }}
scrollZoom={false}
className="h-96"
>
<MapControls />
</Map>
);
}Move it from your code#
mapRef hands you the MapLibre map, as onLoad does and useMap() does inside the map, for anything the props do not cover: here flyTo. When the reader asks for reduced motion, MapLibre jumps instead of flying.
import { useRef } from "react";
import { Button } from "cennge-ui";
import { Map, MapControls, type LngLat, type MapLibreMap } from "cennge-ui/map";
const boroughs: { name: string; center: LngLat; zoom: number }[] = [
{ name: "Manhattan", center: [-73.9712, 40.7831], zoom: 11.4 },
{ name: "Brooklyn", center: [-73.9442, 40.6782], zoom: 11.2 },
{ name: "Queens", center: [-73.7949, 40.7282], zoom: 10.8 },
{ name: "The Bronx", center: [-73.8648, 40.8448], zoom: 11.4 },
{ name: "Staten Island", center: [-74.1502, 40.5795], zoom: 11.2 },
];
export default function BoroughMap() {
const mapRef = useRef<MapLibreMap | null>(null);
return (
<div className="flex w-full flex-col gap-3">
<div className="flex flex-wrap gap-2">
{boroughs.map((borough) => (
<Button
key={borough.name}
size="sm"
variant="flat"
// MapLibre jumps instead of flying when the reader asks for reduced motion.
onClick={() => mapRef.current?.flyTo({ center: borough.center, zoom: borough.zoom })}
>
{borough.name}
</Button>
))}
</div>
<Map
aria-label="New York boroughs"
mapRef={mapRef}
defaultCenter={[-73.94, 40.7]}
defaultZoom={9.6}
scrollZoom={false}
className="h-80"
>
<MapControls />
</Map>
</div>
);
}In a page that scrolls#
With scrollZoom={false} the mouse wheel scrolls the page past the map, and the buttons, a double-click, the keys and a pinch on a touch screen still zoom it. Every map on these pages starts that way. scrollZoom can change after the map has mounted; isInteractive, minZoom and maxZoom are read once, when it is created.
import { useState } from "react";
import { Switch } from "cennge-ui";
import { Map, MapControls } from "cennge-ui/map";
export default function ScrollZoomMap() {
const [scrollZoom, setScrollZoom] = useState(false);
return (
<div className="flex w-full flex-col gap-4">
<Map aria-label="Brooklyn" defaultCenter={[-73.95, 40.66]} defaultZoom={11.5} scrollZoom={scrollZoom} className="h-72">
<MapControls />
</Map>
<Switch isSelected={scrollZoom} onValueChange={setScrollZoom}>
Zoom with the mouse wheel
</Switch>
</div>
);
}A still picture#
isInteractive={false} draws the place and nothing more: no panning, no zooming, and the canvas leaves the tab order. Markers on it are still read out by name.
Manhattan office
Midtown, Manhattan
Brooklyn office
Dumbo, Brooklyn
import { Card, CardBody } from "cennge-ui";
import { Map, MapMarker, type LngLat } from "cennge-ui/map";
const offices: { name: string; area: string; lngLat: LngLat }[] = [
{ name: "Manhattan office", area: "Midtown, Manhattan", lngLat: [-73.9855, 40.758] },
{ name: "Brooklyn office", area: "Dumbo, Brooklyn", lngLat: [-73.9888, 40.7033] },
];
export default function OfficeCards() {
return (
<div className="@container w-full">
<div className="grid gap-4 @lg:grid-cols-2">
{offices.map((office) => (
<Card key={office.name}>
<Map
aria-label={`Map of ${office.area}`}
isInteractive={false}
defaultCenter={office.lngLat}
defaultZoom={14.5}
className="h-40 rounded-none border-0"
>
<MapMarker lngLat={office.lngLat} label={office.name} />
</Map>
<CardBody>
<p className="font-semibold">{office.name}</p>
<p className="text-sm text-muted">{office.area}</p>
</CardBody>
</Card>
))}
</div>
</div>
);
}Other base maps#
mapStyle takes any MapLibre style, as a URL or an object, or one per theme as { light, dark }. openFreeMapStyles has OpenFreeMap's styles as published, and blankMapStyle(color) draws nothing but a background, for data that stands on its own. Routes and data layers are added again after a swap, and markers stay where they are.
import { useState } from "react";
import { ToggleGroup, ToggleGroupItem } from "cennge-ui";
import { Map, MapControls, openFreeMapStyles, type MapProps } from "cennge-ui/map";
const styles: Record<string, MapProps["mapStyle"]> = {
// undefined: the default, OpenFreeMap's Positron in the theme's colours.
Theme: undefined,
Liberty: openFreeMapStyles.liberty,
Bright: openFreeMapStyles.bright,
// One style per theme, swapped with it.
"Positron and Dark": { light: openFreeMapStyles.positron, dark: openFreeMapStyles.dark },
};
export default function BaseMaps() {
const [name, setName] = useState("Theme");
return (
<div className="flex w-full flex-col gap-3">
<ToggleGroup aria-label="Base map" size="sm" value={name} onValueChange={(next) => next && setName(next)} className="self-start">
{Object.keys(styles).map((style) => (
<ToggleGroupItem key={style} value={style}>
{style}
</ToggleGroupItem>
))}
</ToggleGroup>
<Map
aria-label="Lower Manhattan"
mapStyle={styles[name]}
defaultCenter={[-74.003, 40.712]}
defaultZoom={13.5}
scrollZoom={false}
className="h-80"
>
<MapControls />
</Map>
</div>
);
}Accessibility#
- Give every map an
aria-label: it is a region with that name. The canvas inside is in the tab order and says how to move it; its focus ring is drawn around the whole frame. - Each control is a button with a name and a tooltip. Zoom in and zoom out are disabled at
maxZoomandminZoom. When the reader's location is not available, the locate button is renamed to say so, and a status message announces it. - When the reader asks for reduced motion, or inside
<CenngeProvider reducedMotion="always">, the buttons move the map without animating it, and the location dot does not pulse. labelstranslates the names and messages the map, its controls and its popups add, the canvas's instructions included. Street and place names are pixels on the canvas: when the place is the information, write it out as text too.
Server rendering#
On the server the map is its frame: the region at its size, with data-state="loading" and a shimmer. Its children render once maplibre-gl has loaded in the browser, and the page does not shift when it does.
MapLibre draws in a worker, and bundlers do not find its file on their own. In Next.js, copy the worker's two files to public/maplibre/, say in a postinstall script:
mkdir -p public/maplibre
cp node_modules/maplibre-gl/dist/maplibre-gl-worker.mjs public/maplibre/
cp node_modules/maplibre-gl/dist/maplibre-gl-shared.mjs public/maplibre/Then point the maps to them from a client component rendered once in the root layout:
// app/map-worker.tsx; <MapWorker /> goes in app/layout.tsx
"use client";
import { setMapWorkerUrl } from "cennge-ui/map";
setMapWorkerUrl("/maplibre/maplibre-gl-worker.mjs");
export function MapWorker() {
return null;
}With Vite, import the worker's URL instead. Unlike maplibre-gl's own setWorkerUrl, setMapWorkerUrl does not import maplibre-gl, so it still loads only with a map.
import workerUrl from "maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url";
import { setMapWorkerUrl } from "cennge-ui/map";
setMapWorkerUrl(workerUrl);Styling#
The frame is w-full h-80, with the large radius and a border. Set its size with className, say h-96 or aspect-video. MapControls go in any corner with position. The base map's credits stay in the bottom-right corner, as its licences ask.
Props#
Map#
Also takes every attribute of <div>, except children, onClick, onLoad, which mean something else here.