APOLLO VISION LABS

Accessibility

What guide does for a keyboard user and a screen reader, and the deliberate exceptions.

The accessibility work lives in the core, in a11y.ts, so a custom renderer gets the same primitives the MUI layer uses. This page describes what the shipped code does. It does not describe an intention.

Focus at the start and at the end of a tour

start() records document.activeElement before anything changes. The popover unmounts and remounts on every step, so its own trap cannot be the thing that restores focus at the end: the provider holds the origin instead.

While a step is on screen, focus is inside the popover. When the tour reaches idle or completed, focus returns to the recorded origin, if that element is still in the document. If the origin has been removed in the meantime, nothing is forced.

The focus trap

useFocusTrap(container, active, options) holds Tab inside the container while active is true. It listens for Tab in the capture phase, and wraps from the last focusable element to the first and back. The focusable set is a[href], enabled button, textarea, input and select, and anything with a tabindex other than -1. There is no visibility filter: the popover mounts and unmounts its controls rather than hiding them.

initialFocus decides where focus lands on entry. The default, 'first', takes the first focusable element. StepPopover passes 'container' instead, so focus lands on the dialog itself, which carries tabIndex={-1}. That is deliberate: with focus on the close button, a reflex Enter after an arrow key would end the tour.

On teardown the trap restores focus to whatever was focused when it was installed.

Keyboard on a step

While a step is open, StepPopover listens on the document:

Key Effect
Escape Stops the tour
ArrowRight Next step, or finishes on the last one
ArrowLeft Previous step, ignored on the first

The handler stands down when the event target is a typing surface: an input, a textarea, a select, or any contenteditable element. Arrow keys inside a field move the caret, which is what they are for on an interactive step.

While a step is waiting for its target, nothing is drawn: no popover, no overlay, no spinner. An Escape handler is still mounted for exactly that window, so a tour that is running but invisible can always be ended from the keyboard.

What a screen reader gets

The popover is a role="dialog", labelled by its title and described by its body. The close button carries an accessible name, taken from labels.close, which defaults to Close. Translating it is not cosmetic.

While the step is open, the highlighted element itself is given aria-describedby pointing at the popover body, and the attribute is removed when the step ends. So a user who moves to the element hears what the step says about it.

The spotlight overlay is aria-hidden="true". It is decoration, and it has nothing to read.

The live announcer

useAnnouncer() returns a function that writes into a single shared node, created once, carrying aria-live="polite" and aria-atomic="true" and positioned off screen. GuideProvider calls it when a step is genuinely on screen, that is when the tour is running and the target element has been resolved, with the step position in the form 1 / 3.

That is the whole announcement. The title and body are not announced by the live region: they are read from the dialog when focus enters it.

Reduced motion

usePrefersReducedMotion() reads (prefers-reduced-motion: reduce) and subscribes to changes on the media query, so a setting changed mid session takes effect. The spotlight is the only animated part: the hole normally transitions over 200 ms as it moves from one target to the next, and under reduced motion it has no transition at all and jumps.

Why an interactive step is not modal

GuideTour passes modal={!active.interactive} to the popover. Note active.interactive rather than active.step.interactive: the core derives the modality itself, because a step declaring advanceOn also has to let the click through, or it would be waiting for an action it makes impossible. A step marked interactive: true, like a step waiting for a click, is therefore rendered without the focus trap and without aria-modal, and the overlay stops receiving pointer events.

A modal dialog is a keyboard prison by design: Tab cycles inside it and never reaches the page. A step that asks someone to click a button while holding their keyboard away from that button is asking for something they cannot do. So an interactive step gives the keyboard back. The cost is real: focus is no longer held, and the step is easier to lose track of. Use it for the steps that ask for an action, not as a default.

The checklist row: a checkbox beside the button, not inside it

Each row is a ListItem whose secondaryAction is the checkbox and whose child is a ListItemButton. The two are siblings.

They do different things, which is the reason for the shape. The button activates the item: it starts the linked tour, or navigates to the item’s href, or, for an item with neither, toggles it. The checkbox only ticks and unticks. Nesting the checkbox inside the button would put one control inside another, leaving a keyboard user with one stop for two actions and no way to reach the second. As siblings, both are in the tab order and each does one thing.

The checkbox carries its own accessible name, which states the action rather than the state. Its defaults are Mark <item title> as complete, and Mark <item title> as not complete once it is ticked. Both come from labels.markComplete and labels.markNotComplete on Checklist, so an application that is not in English replaces them rather than shipping the defaults.

The progress bar’s value is rounded in the component rather than left to MUI, because MUI 7 and MUI 9 round aria-valuenow differently and both are supported peers. The percentage a screen reader announces is therefore the same on either.

The launcher’s accessible name

The launcher is a floating button showing 2/5, with a progress ring drawn around it. The ring is aria-hidden="true": it is a shape, and it says nothing to anyone who is not looking at it.

So the count goes into the button’s accessible name, in words:

Get started, 2 of 5 complete

That is labels.fabLabel on ChecklistLauncher, whose default is (title, done, total) => `${title}, ${done} of ${total} complete`. It receives the launcher’s title, falling back to Checklist when none is passed. The popover it opens is a role="dialog" with aria-modal="true", labelled with the same title.

The popover stays open when an item is simply ticked, because ticking items one after another is the normal way to use the list. It closes only when the activated item hands off to something that needs the screen: a tour, or a navigation.

What a dismissal leaves behind

Dismissing the checklist from inside the popover unmounts the button and the popover in the same commit. There is then no anchor left for MUI to restore focus to, and a keyboard user is dropped on document.body: no focus ring, no announcement, and the next Tab restarts at the top of the page.

The launcher does not stay on screen to avoid this, because disappearing is the point of a dismissal. Instead it leaves one off screen element at the place it just vacated, focuses it, and removes it as soon as focus moves on. Its text comes from labels.dismissed, which defaults to <title> dismissed.

Two details are deliberate. That element is not a live region: focusing it is what announces it, and a polite live region inserted and focused in the same commit is read twice by several screen readers. And it appears only when the dismissal happened here, in this session, from inside the popover. A checklist that storage already reports as dismissed renders nothing at all, so a returning user is not told about something they did last week.

What a non-English application has to replace

Everything the checklist components draw for themselves has an English default, and every one of those defaults is a labels entry: the dismiss button, the progress text beside the title, the two checkbox names, the launcher’s accessible name, and the dismissal message described above. The defaults are what a screen reader reads when nothing is supplied, which is the reason they are spelled out on this page. Checklist takes Partial<ChecklistLabels>; ChecklistLauncher takes Partial<ChecklistLauncherLabels> and passes them on to the list inside its popover. The entries carrying a count or an item title are functions, so the word order around that value is yours.

The tour side has the same escape hatch in StepPopoverLabels, through labels on GuideTour.

  • Tours for interactive and step options.
  • Checklist for the launcher and item behaviour.
  • API reference for useFocusTrap, useAnnouncer and usePrefersReducedMotion.