Markers and popups
Places on the map as React elements: a dot or your own marker, a tooltip on hover and focus, a popup with anything in it.
- Point at a marker or Tab to it: its tooltip names the place.
- Click it, or press Enter or Space: its popup opens with focus in it. Escape closes it and puts focus back on the marker.
- A click on the map closes the popup; one that would be cut off moves the map until it fits.
Field marks
- Import
import { MapMarker, MapPopup } from "cennge-ui/map";- Size
- 7.0 KB gzip for this import, its dependencies aside
- Pattern
- A
<button>with a popup oronClick,role="img"without; the popup is a non-modalrole="dialog" - Keyboard
- Tab Enter SpaceEsc
- State
- Popup controlled with
isPopupOpenandonPopupOpenChange, or uncontrolled;MapPopupis always controlled - SSR
- Rendered on the server in the Next.js check; hydrates without a warning
- Target
- 24 px at the least around the dot, 32 px at size lg
- Moves
- With the map, frame by frame, without a React render
- 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 MapMarker for a place a reader looks for or picks: an office, a store, a stop. It is an element over the canvas, not a picture in it, so it takes your components and classes, a focus ring and a screen reader's attention. Give it a popup for details and actions, a tooltip for the name alone.
For a point that is not a marker, say where the reader clicked or a search result, open a MapPopup at it.
Similar#
- MapClusterLayer is for hundreds or thousands of points: it groups the ones that crowd and puts in the page only those in view.
- MapGeoJSON draws points into the canvas with a tooltip at the pointer: no tab stops, for data rather than places to pick.
- Popover anchors content to an element on the page; a map's popup stays on its point as the map moves.
A marker with a popup#
The marker is named by label, which also names its popup. The tooltip shows on hover and on keyboard focus, and hides while the popup is open.
import { Map, MapMarker, type LngLat } from "cennge-ui/map";
const dumbo: LngLat = [-73.9888, 40.7033];
export default function OfficeMarker() {
return (
<Map aria-label="Brooklyn office" defaultCenter={dumbo} defaultZoom={13.5} scrollZoom={false} className="h-72">
<MapMarker
lngLat={dumbo}
label="Brooklyn office"
tooltip="Dumbo"
popup={
<>
<p className="font-semibold">Brooklyn office</p>
<p className="text-muted">Dumbo, Brooklyn</p>
</>
}
/>
</Map>
);
}Your own marker#
Children replace the dot: a label, an avatar, a price. With anchor="bottom" the marker stands on its point like a pin, and its popup opens above it. When the marker shows text, start its label with that text, so a reader using voice control can say what they see.
import { Map, MapMarker, type LngLat } from "cennge-ui/map";
const offices: { area: string; borough: string; lngLat: LngLat }[] = [
{ area: "Midtown", borough: "Manhattan", lngLat: [-73.9855, 40.758] },
{ area: "Dumbo", borough: "Brooklyn", lngLat: [-73.9888, 40.7033] },
{ area: "Flushing", borough: "Queens", lngLat: [-73.8331, 40.7675] },
];
function Pin({ children }: { children: string }) {
return (
<span className="flex flex-col items-center">
<span className="rounded-full bg-primary px-2.5 py-1 text-xs font-semibold text-primary-foreground shadow-raised">
{children}
</span>
{/* The tip, on the point: the marker is anchored at its bottom. */}
<span className="-mt-px h-1.5 w-3 bg-primary [clip-path:polygon(0_0,100%_0,50%_100%)]" />
</span>
);
}
export default function OfficePins() {
return (
<Map
aria-label="Offices by neighbourhood"
defaultBounds={[
[-74.0, 40.69],
[-73.82, 40.77],
]}
scrollZoom={false}
className="h-80"
>
{offices.map((office) => (
<MapMarker
key={office.area}
lngLat={office.lngLat}
// Starts with the text the pin shows, so it can be said to pick it.
label={`${office.area}, ${office.borough}`}
anchor="bottom"
popup={
<>
<p className="font-semibold">{office.area} office</p>
<p className="text-muted">{office.borough}</p>
</>
}
>
<Pin>{office.area}</Pin>
</MapMarker>
))}
</Map>
);
}Opened from a list#
With isPopupOpen and onPopupOpenChange the page decides which popup is open, so a list beside the map can open one. If the popup would be cut off, the map moves until it fits.
import { useState } from "react";
import { Map, MapMarker, type LngLat } from "cennge-ui/map";
const offices: { name: string; area: string; lngLat: LngLat; repositories: string }[] = [
{ name: "Manhattan office", area: "Midtown", lngLat: [-73.9855, 40.758], repositories: "cennge-ui, payload-cache" },
{ name: "Brooklyn office", area: "Dumbo", lngLat: [-73.9888, 40.7033], repositories: "spredo-crypto-screener" },
{ name: "Queens office", area: "Flushing", lngLat: [-73.8331, 40.7675], repositories: "ai-niche-scout" },
{ name: "Bronx office", area: "Fordham", lngLat: [-73.8977, 40.8622], repositories: "playwright-msi-scraper" },
];
export default function OfficeFinder() {
const [open, setOpen] = useState<string | null>(null);
return (
// Side by side where there is room, the list above the map where there is not.
<div className="@container w-full">
<div className="grid gap-4 @xl:grid-cols-[12rem_minmax(0,1fr)]">
<ul aria-label="Office list" className="flex flex-wrap gap-1 @xl:flex-col">
{offices.map((office) => (
<li key={office.name}>
<button
type="button"
aria-pressed={open === office.name}
onClick={() => setOpen(open === office.name ? null : office.name)}
className="w-full cursor-pointer rounded-medium px-3 py-2 text-left transition-colors hover:bg-surface-3 focus-visible:outline-2 focus-visible:outline-ring focus-visible:outline-solid aria-pressed:bg-primary-bg"
>
<span className="block text-sm font-semibold">{office.name}</span>
<span className="block text-sm text-muted">{office.area}</span>
</button>
</li>
))}
</ul>
<Map
aria-label="Office map"
defaultBounds={[
[-74.0, 40.69],
[-73.83, 40.87],
]}
scrollZoom={false}
className="h-96"
>
{offices.map((office) => (
<MapMarker
key={office.name}
lngLat={office.lngLat}
label={office.name}
tooltip={office.area}
isPopupOpen={open === office.name}
onPopupOpenChange={(isOpen) => setOpen(isOpen ? office.name : null)}
popup={
<>
<p className="font-semibold">{office.name}</p>
<p className="text-muted">{office.repositories}</p>
</>
}
/>
))}
</Map>
</div>
</div>
);
}A popup at any point#
MapPopup opens at any point, not at a marker: here, where the map was clicked. A click elsewhere on the map is left to onMapClick, which moves it there.
Click the map to pick a point.
import { useState } from "react";
import { Button } from "cennge-ui";
import { Map, MapMarker, MapPopup, type LngLat } from "cennge-ui/map";
const format = ([lng, lat]: LngLat) => `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
export default function DeliveryStops() {
const [picked, setPicked] = useState<LngLat | null>(null);
const [stops, setStops] = useState<LngLat[]>([]);
function addStop(point: LngLat) {
setStops([...stops, point]);
setPicked(null);
}
return (
<div className="flex w-full flex-col gap-3">
<Map
aria-label="Delivery stops"
defaultCenter={[-73.99, 40.735]}
defaultZoom={12.5}
scrollZoom={false}
onMapClick={setPicked}
className="h-80"
>
{stops.map((stop, index) => (
<MapMarker key={format(stop)} lngLat={stop} label={`Stop ${index + 1}`}>
<span className="grid size-6 place-items-center rounded-full bg-secondary text-xs font-bold text-secondary-foreground shadow-raised">
{index + 1}
</span>
</MapMarker>
))}
{picked ? (
<MapPopup lngLat={picked} isOpen onOpenChange={() => setPicked(null)} label="Picked point">
<p className="text-muted">Picked point</p>
<p className="mb-2.5 font-medium tabular-nums">{format(picked)}</p>
<Button size="sm" color="secondary" onClick={() => addStop(picked)}>
Add stop {stops.length + 1}
</Button>
</MapPopup>
) : null}
</Map>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-muted">{stops.length === 0 ? "Click the map to pick a point." : stops.length === 1 ? "1 stop" : `${stops.length} stops`}</p>
<Button size="sm" variant="light" isDisabled={!stops.length} onClick={() => setStops([])}>
Clear
</Button>
</div>
</div>
);
}Accessibility#
- A marker with a
popupor anonClickis a button in the tab order, named bylabel, witharia-haspopup="dialog"andaria-expandedwhen it has a popup. Without either it is an image with that name. - The tooltip is the marker's description (
aria-describedby): read after its name, shown on hover and on keyboard focus. - A popup opened by a click or a key takes focus; one opened by the page, with
isPopupOpenordefaultPopupOpen, does not. Escape inside it and its close button close it, and focus goes back to the marker. A click on the map closes it and leaves focus where it went. MapPopuptakes focus only withautoFocus; pass it when the popup opens from the keyboard.- The marker in use, with its tooltip and popup, is drawn above its neighbours.