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.
Related
- Tours for
interactiveand step options. - Checklist for the launcher and item behaviour.
- API reference for
useFocusTrap,useAnnouncerandusePrefersReducedMotion.