View all posts

How to build an accessible map: focus management, boundaries and coordinates

Introduction

Points of interest

Focus management

Every point of interest is rendered twice: once as an AdvancedMarker on the canvas, and once as a <button> inside a real list. The markers are for people who look at the map. The buttons are the map, for everybody else.

Where focus goes when a pin opens

Selecting a place from the list opens its detail. That means the button that opened it has to say so:

<button
  aria-current={active ? 'true' : undefined}
  aria-expanded={expandable ? active : undefined}
  aria-controls={expandable ? detailId : undefined}
>

aria-expanded announces the state, aria-controls connects the button to the detail it reveals, and aria-current marks which place is the active one.

Where focus goes when a pin closes

If you open a detail and close it, focus has to come back to the button that opened it. If it doesn't, focus falls back to the document and the next Tab starts from the top of the page, which means the user has to walk through the whole header again to get back to a list they were already reading.

const itemRefs = useRef<Record<string, HTMLButtonElement | null>>({})

const closeSelection = () => {
  const previous = selectedId
  setSelectedId(null)
  if (previous) {
    requestAnimationFrame(() => itemRefs.current[previous]?.focus())
  }
}

The requestAnimationFrame matters. React hasn't removed the balloon yet at the moment closeSelection runs, so calling .focus() straight away can land on an element that's about to be unmounted.

Where focus goes after a search

When a search resolves, focus returns to the search field:

inputRef.current?.focus()

The results themselves aren't announced by moving focus, because moving focus to announce something steals control from the user. They're announced with live regions instead: role="status" with aria-live="polite" while a location is resolving, and role="alert" when an address can't be geocoded, when geolocation is denied, or when a place falls outside the area we allow.

Boundaries and coordinates

Boundary

A box of four coordinates (north, south, east, west) that says where the map is allowed to go.

Region path

A list of coordinates that draws the outline of the country on top of the map.

Coordinate

A lat and a lng. Every pin, every search result and the visitor's own position are all the same shape.

The preset declares where the map lives, and the coordinates do the work:

const italy: MapPreset = {
  searchCountry: 'IT',
  bounds: { north: 47.1, south: 36.6, east: 18.6, west: 6.6 },
  defaultZoom: 6,
  minZoom: 5,
}

Those four numbers get handed to the map as a restriction:

restriction={bounds ? { latLngBounds: bounds, strictBounds: false } : undefined}

An intuitive UI

role="search", a labelled input, and a submit button with an accessible name, because the button only shows a magnifying glass.

<button aria-label={labels.searchSubmit}>
  <Search aria-hidden="true" className="h-4 w-4" />
</button>

The icon is aria-hidden since the accessible name already covers it, and repeating it would make the button announce itself twice.

const mergedLabels = { ...DEFAULT_LABELS, ...labels }

A map is text-heavy once you build it for people who can't see it, and those strings are the interface. Hardcoding them would have made the component work in one language on the first day.

If the API key never arrives, the component renders a message with role="alert" where the map would be, so the failure is visible and announced.

Resources

BESbswy