Skip to content
cennge-ui
GitHubv0.3.1, changelog

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.

  1. Point at a marker or Tab to it: its tooltip names the place.
  2. Click it, or press Enter or Space: its popup opens with focus in it. Escape closes it and puts focus back on the marker.
  3. A click on the map closes the popup; one that would be cut off moves the map until it fits.
Live: the component itself, not a picture of it.

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 or onClick, role="img" without; the popup is a non-modal role="dialog"
Keyboard
Tab Enter SpaceEsc
State
Popup controlled with isPopupOpen and onPopupOpenChange, or uncontrolled; MapPopup is 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.

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

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

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

click-popup.tsx
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 popup or an onClick is a button in the tab order, named by label, with aria-haspopup="dialog" and aria-expanded when 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 isPopupOpen or defaultPopupOpen, 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.
  • MapPopup takes focus only with autoFocus; pass it when the popup opens from the keyboard.
  • The marker in use, with its tooltip and popup, is drawn above its neighbours.

Props#

MapMarker#

PropTypeDefault
lngLatrequired
LngLat
–
labelrequired
stringWhat the marker stands for: read by screen readers, and the title of its popup.
–
tooltip
React.ReactNodeShown on hover and keyboard focus.
–
popup
React.ReactNodeOpens on click, Enter or Space, above the marker.
–
isPopupOpen
boolean
–
defaultPopupOpen
boolean
–
onPopupOpenChange
(isOpen: boolean) => void
–
onClick
() => voidMakes the marker a button even without a popup.
–
color
"default""primary""secondary""success""warning""danger""info"Of the default dot.
"primary"
size
"sm""md""lg"Of the default dot.
"md"
anchor
"center""bottom"Which part of the marker is on the point: its middle, or its bottom (a pin).
"center"
children
React.ReactNodeYour own marker in place of the dot: an avatar, a price, an icon.
–
className
string
–

MapPopup#

PropTypeDefault
lngLatrequired
LngLatThe point the popup's arrow shows.
–
isOpenrequired
boolean
–
onOpenChangerequired
(isOpen: boolean) => void
–
labelrequired
stringIts name for screen readers.
–
autoFocus
booleanFocus moves into the popup when it opens, e.g. when opened from the keyboard.
false
className
string
–
childrenrequired
React.ReactNode
–