FSD Frontend System Design

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.

  • Time16 min
  • DiagramFocus state machine for a dialog + SPA route change
  • Chapter8 Β· Accessibility

Diagram

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 were
diagram
SPA 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)
diagram
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 instead

How it works

  1. Save the trigger before opening any overlay, and restore focus to it on close.
  2. Trap focus inside modals β€” cycle Tab within the dialog and make the rest of the page inert.
  3. Never focus something the user didn't cause. Autofocus on page load and focus stealing from toasts are both hostile.
  4. On route change, move focus to the new <h1> with tabindex="-1", update the title, and announce it.
  5. On form error, focus a summary listing the problems and link each entry to its field.
js
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

DoDon't
Restore focus to the triggerDrop focus to <body>
Trap focus in modalsTrap focus anywhere else
Focus the first field in a form dialogAutofocus on every page load
Move focus after destructive actionsLeave 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.