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
latand alng. 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.