View all posts

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

Introduction

Welcome to a new entry in my section dedicated to creating reusable and, above all, accessible components. This time we're going to work with something that doesn't come accessible by default and never will: a map. A Google map paints itself onto a canvas, and a canvas is a picture. You can't put a tabindex on a pixel, and you can't give a marker an accessible name by wishing for one.

In this article I'll explain how I handled the focus inside the map, why boundaries and coordinates are the part of the work nobody talks about, and how the UI has to stay intuitive for people who never see it.

Points of interest

Focus management inside the map

The map isn't the interactive element. The list next to it is.

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. Anything you can do by clicking a pin you can do from the list, and that's the rule I keep coming back to when I add a feature.

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. Without these three, a screen reader user presses a button and the page silently changes somewhere else.

Where focus goes when a pin closes

This is the part that gets forgotten. 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.

I keep a ref for every list item, keyed by the id of the place:

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. Waiting one frame lets the DOM settle first.

Where focus goes after a search

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

inputRef.current?.focus()

The reasoning is that a search is a loop. You type something, you look at what came back, and very often you type something else. Sending focus to the results would win one interaction and cost every one after it.

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. The user hears what happened and keeps their place.

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.

A map with no boundary lets you drag until you're looking at the middle of the Pacific. Nothing breaks, and the user has no idea how to get back. Zoom out far enough and you get the world three times over, side by side.

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

Boundaries –– Code Snippet

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}

I keep strictBounds off on purpose. With it on, the viewport refuses to move once an edge touches the box, and around the border of the country the map starts fighting the user. With it off the edges stay reachable and the map pulls back gently.

minZoom belongs to the same idea. Below zoom 5 you stop looking at Italy and start looking at Europe with a piece of Italy in it, so there's no reason to allow it.

Coordinates are what make the map readable as text

This is the part I care about most. Coordinates aren't only for drawing. Once every place is a lat and a lng, I can measure, and once I can measure, I can write a sentence.

The distance between the visitor and each place is calculated and rendered as text next to the name, and the list is sorted by it. Someone looking at the map reads proximity from the picture. Someone using a screen reader reads "12 km" and gets the same fact. The coordinates carry the information across, from a shape into a string.

The same coordinates decide what's even allowed to appear. searchCountry: 'IT' restricts geocoding to Italy, so a search for a street that exists in ten countries resolves to the one we're showing, and a visitor located outside the country gets a clear message instead of an empty map. The country outline comes from regionPaths, a list of rings of coordinates, drawn as a polygon so the shape you can pan around matches the shape we say the map covers.

An intuitive UI

Accessibility work fails when it produces something technically correct that nobody wants to use. A few decisions here were about the interface being obvious.

On mobile there's no map. The canvas is hidden and the list is the entire interface, with each place expanding its own detail in place. A map on a small screen is a postage stamp you fight with one thumb while it swallows your scroll. Removing it made the small screen better for everyone, and it also happens to be the version that works without sight.

The search is a real form. role="search", a labelled input, and a submit button with an accessible name, because the button only shows a magnifying glass. Icon-only controls are fine as long as they're named:

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

Every string is a label. There's no hardcoded English in the component. The search field, the "searching" message, "nothing found", "your location", the name of the list: all of it arrives through a labels object merged over defaults.

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.

When something's missing, say so. 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