Skip to content
cennge-ui
GitHubv0.3.1, changelog

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}
  1. 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.
  2. Tab to the map: arrows move it, + and โˆ’ zoom, Shift with the arrows turns and tilts it.
  3. Move it, then copy the props that open a map on that view.
Live: the component itself, not a picture of it.

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 your aria-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 defaultCenter and defaultZoom, or defaultBounds; onViewChange reports 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#

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.

bounds.tsx
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.

fly-to.tsx
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.

scroll-zoom.tsx
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

still.tsx
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.

styles.tsx
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 maxZoom and minZoom. 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.
  • labels translates 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#

PropTypeDefault
defaultCenter
LngLatWhere the map opens.
[0, 20]
defaultZoom
number
1.5
defaultBounds
[southWest: LngLat, northEast: LngLat]Opens on this area instead, at whatever zoom fits it: say, around every marker. Its corners are south-west and north-east. Wins over defaultCenter and defaultZoom.
โ€“
boundsPadding
number | { top: number; right: number; bottom: number; left: number }Room around defaultBounds, in pixels: e.g. more on the side of the controls.
48
defaultBearing
numberRotation in degrees, clockwise from north.
0
defaultPitch
numberTilt in degrees.
0
minZoom
number
โ€“
maxZoom
number
โ€“
mapStyle
MapStyle | { light: MapStyle; dark: MapStyle }A MapLibre style (URL or object), or one per theme. openFreeMapStyles has OpenFreeMap's as published.
OpenFreeMap's Positron in the theme's colours, light or dark: no key, no usage limits
theme
"light""dark"
the page's: dark inside `class="dark"`
isInteractive
booleanOff: a still picture of the place, no panning or zooming.
true
scrollZoom
booleanOff: the mouse wheel scrolls the page past the map instead of zooming it.
true
onLoad
(map: MapLibreMap) => voidOnce the first style has loaded, with the MapLibre map for anything the props do not cover.
โ€“
onViewChange
(view: MapView) => voidAfter each move: drag, zoom, rotation or an animation, once it settles.
โ€“
onMapClick
(lngLat: LngLat) => voidA click on the map itself, not on a marker or a control.
โ€“
labels
Partial<MapLabels>Texts of the controls, for other languages.
โ€“
mapRef
React.Ref<MapLibreMap | null>Receives the MapLibre map once it exists.
โ€“
children
React.ReactNode
โ€“

Also takes every attribute of <div>, except children, onClick, onLoad, which mean something else here.

MapControls#

PropTypeDefault
position
"top-right""top-left""bottom-right""bottom-left"
"top-right"
showZoom
boolean
true
showCompass
booleanTurns the map back to north; its needle shows the rotation.
true
showLocate
booleanFinds the user (the browser asks first) and shows them on the map.
false
showFullscreen
boolean
false
className
string
โ€“