Part of the Popups, Tooltips & Feature Interaction guide.
Operative rule: every fact a reader can reach by pointing at the map must also be reachable by tabbing through the page — which, for a canvas renderer, means generating a parallel list of features in the DOM.
Why the Map Itself Cannot Be Made Accessible
A Leaflet map built from SVG overlays has real DOM elements per feature, and those can be given roles, labels and focusability. A canvas or WebGL map has one element. There is no per-feature node to focus, no text to announce, and no structure to traverse — the entire map is a single opaque image as far as assistive technology is concerned.
The remedy that works across every renderer is to stop trying to make the drawing accessible and instead publish the data accessibly. A short, focusable list of the features currently in view, rendered in the DOM next to the map, gives keyboard users a route to every value, and gives screen-reader users something that can actually be read. The map remains the visual representation; the list is the accessible one, and they stay in sync.
Production-Ready Implementation
const MAX_ENTRIES = 50;
function visibleFeatures(map, layerId) {
const seen = new Set();
const out = [];
for (const f of map.queryRenderedFeatures({ layers: [layerId] })) {
if (f.id === undefined || seen.has(f.id)) continue; // tiles repeat features
seen.add(f.id);
out.push(f);
if (out.length >= MAX_ENTRIES) break;
}
return out.sort((a, b) =>
String(a.properties.name).localeCompare(String(b.properties.name)));
}
export function buildFeatureList(map, layerId, listEl, statusEl, openDetail) {
function render() {
const features = visibleFeatures(map, layerId);
listEl.replaceChildren(
...features.map((f) => {
const li = document.createElement("li");
const button = document.createElement("button");
button.type = "button";
button.textContent =
`${f.properties.name}: ${f.properties.incidents_per_1k} per 1k`;
button.addEventListener("focus", () => {
map.setFeatureState({ source: layerId, sourceLayer: layerId, id: f.id },
{ hover: true });
});
button.addEventListener("blur", () => {
map.setFeatureState({ source: layerId, sourceLayer: layerId, id: f.id },
{ hover: false });
});
button.addEventListener("click", () => openDetail(f));
li.appendChild(button);
return li;
})
);
// Announce the COUNT, not the contents — a polite summary, once per move.
statusEl.textContent =
`${features.length} feature${features.length === 1 ? "" : "s"} in view`;
}
map.on("moveend", render); // never on "move" — that would announce constantly
map.once("idle", render);
}
Two decisions in that code are the ones that make it usable rather than merely compliant. The list rebuilds on moveend, not on move, so a reader panning the map is not bombarded with announcements. And the live region announces a count rather than the features themselves — a summary a screen-reader user can act on, instead of fifty names read aloud every time the map settles.
What the Markup Has to Get Right
Keeping Announcements Quiet Enough to Be Useful
Accessibility work fails as often from too much output as from too little. A live region wired to every camera change turns a screen reader into a metronome; entries that announce their full record make a list of twenty features take two minutes to traverse.
The rule that keeps it usable: each entry announces the minimum that identifies it, the live region announces only counts and only after motion has stopped, and everything else is available on demand by activating the entry. Detail belongs behind an explicit action, exactly as it does for sighted readers who click rather than hover.
The focus path a keyboard reader actually takes
Tab order is the whole experience for a keyboard user, and on a map page it is easy to get wrong: a reader can end up traversing a dozen zoom and layer controls before reaching any data at all. Laying the path out explicitly makes the problem obvious and the fix short.
Verification Steps
- Tab through the page with the mouse unplugged and confirm every feature in view can be reached and activated.
- Confirm focusing an entry visibly highlights the corresponding feature on the map.
- Pan continuously and confirm the live region announces once, after the movement ends.
- Run an automated accessibility check on the page and confirm the canvas is not exposing an empty accessible name.
- Confirm the skip link is the first focusable element and actually moves focus past the map controls.
Common Errors & Fixes
The list contains duplicates
Vector tiles repeat a feature in every tile it touches. Deduplicate by feature id before rendering, as the code above does with a Set.
Focus jumps to the top of the page after a pan
The list is being replaced while one of its buttons has focus. Preserve focus by matching on feature id after the rebuild, or defer the rebuild while the list has focus inside it.
Screen readers announce the map as “canvas”
Mark the canvas aria-hidden="true" and give the surrounding figure a real description. An honest “this is a visual representation, the data is in the list beside it” is better than an accessible name on an image nobody can read.
The highlight persists after focus moves away
The blur handler is missing or is clearing the wrong id. Clear on blur, and clear all state when the list is rebuilt.
Ordering, Filtering and the Cap
A list capped at fifty entries is only useful if the fifty are the right ones. Three ordering strategies cover most dashboards, and the choice is worth making deliberately rather than inheriting whatever order the tiles happened to return.
Alphabetical order is the safest default: it is stable between renders, predictable for a reader who is looking for a specific place, and easy to describe. Ordering by the mapped value — highest first — suits dashboards where the reader’s question is “where is the problem worst”, and it puts the features they care about at the top of the list where they are reached with the fewest keystrokes. Ordering by distance from the viewport centre matches what a sighted reader is looking at, which makes the list feel connected to the map rather than parallel to it.
Whichever is chosen, the cap needs a companion. A search field above the list turns “fifty of nine hundred” from a limitation into a workflow: type three characters, get the feature, tab to it. Without one, a capped list silently hides most of the data from exactly the readers who cannot see the map to know what is missing.
The count announcement should reflect both numbers when they differ — “50 of 912 features in view” is honest, and it tells a reader that zooming in will change what they can reach. That single sentence does more for usability than any amount of markup refinement, because it explains the model rather than leaving it to be inferred.
One further refinement is worth the effort on operational dashboards: let the list follow the map’s filter state. If a reader has filtered to one category, the list should contain only that category, because otherwise the accessible representation and the visual one disagree about what is being shown — and the accessible one is the harder of the two to check.
Gotchas & Edge Cases
- A live region that is empty on first render announces nothing when it is first populated in some screen readers; render it with its initial count rather than filling it later.
- Rebuilding the list on every
moveendresets focus if a button inside it currently has focus — match on feature id after the rebuild and restore focus deliberately. - Buttons inside the list must not be disabled when a feature is filtered out; remove them instead, because a disabled control is announced and cannot be acted on.
- The skip link must be the first focusable element in the document, not merely the first inside the map container, or keyboard users still traverse the site navigation first.
- Screen readers announce list semantics, so wrapping entries in a real list element gives “1 of 50” for free — information that is otherwise impossible to convey.
- Keep the entry text short: it is read aloud in full every time focus moves, and a sentence per feature makes traversal unusable long before the cap is reached.
Related
- Popups, Tooltips & Feature Interaction — the parent guide covering affordances and hit testing
- Hover Highlighting with MapLibre Feature State — the highlight mechanism focus drives
- Making Python-Generated Maps Responsive on Mobile — the touch half of the same parity problem
- Dashboard State & URL Sharing — making a selected feature linkable once it can be selected