Part IV β The Reach Β· Lesson 8.4
Focus Management
In one lineWhen the UI changes, you must decide where focus goes β the browser only gets it right on full page loads.
Where you've seen itAn SPA where navigating to page 4 leaves focus on <body>, so the next Tab starts from the logo again.
Diagram
DIALOG FOCUS CYCLE β four moments you must handle
[page]
β user activates "Edit" β remember this element
βΌ
[dialog opens]
β 1. move focus INTO the dialog (first field or the dialog itself)
β 2. TRAP focus: Tab from last β first, Shift+Tab first β last
β 3. mark background inert (inert attribute or aria-hidden)
βΌ
[Esc, Save, or Cancel]
β 4. RESTORE focus to the trigger that opened it
βΌ
[page] β user continues from where they wereSPA ROUTE CHANGE β nothing happens unless you make it
full page load browser resets focus + announces the new title β
SPA navigation URL changes, DOM swaps, focus unchanged β
fix, on every route change:
1. move focus to the <h1> (tabindex="-1") or a skip target
2. update document.title
3. announce via a polite live region (8.3)WHERE FOCUS SHOULD GO β a lookup table
action focus lands on
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
open dialog first input, or the dialog itself
close dialog the trigger that opened it
delete a row the next row, else the previous
submit form with errors the error summary, or first bad field
expand accordion stays on the trigger
route change the new page's <h1>
load more (infinite scroll) stays put β never yank it
toast appears stays put β announce insteadHow it works
- Save the trigger before opening any overlay, and restore focus to it on close.
- Trap focus inside modals β cycle Tab within the dialog and make the rest of the page
inert. - Never focus something the user didn't cause. Autofocus on page load and focus stealing from toasts are both hostile.
- On route change, move focus to the new
<h1>withtabindex="-1", update the title, and announce it. - On form error, focus a summary listing the problems and link each entry to its field.
function openDialog() {
triggerRef.current = document.activeElement; // remember
dialogRef.current.showModal(); // <dialog> traps for you
}
function closeDialog() {
dialogRef.current.close();
triggerRef.current?.focus(); // restore
}The native <dialog> element handles trapping, background inertness, and Escape for you. Prefer it over a hand-built modal.
Trade-offs
| Do | Don't |
|---|---|
| Restore focus to the trigger | Drop focus to <body> |
| Trap focus in modals | Trap focus anywhere else |
| Focus the first field in a form dialog | Autofocus on every page load |
| Move focus after destructive actions | Leave focus on a removed node |
Interview angle
"Your SPA is accessible on first load but not after navigation. Why?"
- Client-side routing swaps the DOM without the focus and announcement reset a real navigation performs.
- Fix by moving focus to the new heading, updating
document.title, and announcing via a polite live region. - The same class of bug hits deletions β focus left on a removed node falls back to
<body>and the user loses their place.
Recap
- Every UI change is a focus decision; the browser only handles full page loads.
- Save the trigger, trap inside modals, restore on close.
- Focusing a removed element silently sends the user back to the top.