PurposeLink to this section
Focus is the keyboard and screen reader user's position in the interface. Every time the DOM changes — a dialog opens, an item is deleted, a route changes — focus either moves somewhere intentional or falls back to <body> and the user is lost at the top of the page. This skill decides the destination for every change.
When to UseLink to this section
- Building dialogs, drawers, popovers, menus, comboboxes, tabs
- Lists where items can be deleted, added, moved, or filtered
- Client-side routing
- Forms with async submission or error summaries
- Inline editing, expand/collapse, "load more", infinite scroll
- Bug reports: "after closing the modal I'm at the top of the page", "screen reader didn't say anything"
Core PrinciplesLink to this section
- Every DOM change that removes or replaces the focused element needs a planned destination. Otherwise focus is lost.
- Move focus only when the user's context changed. Opening a dialog changes context; a toast appearing does not. Unrequested focus moves are disorienting.
- Return focus to where the user came from when a temporary context closes — or to the nearest logical substitute if that element is gone.
- Announce what the user cannot see. Changes that don't move focus but matter (results updated, saved, error) are announced through live regions.
- Visible focus is non-negotiable. A focus destination the user cannot see is not a destination.
- Tab order follows visual and logical order. Use DOM order; avoid positive
tabindex.
WorkflowLink to this section
1. List the focus eventsLink to this section
For the feature, list every interaction that opens, closes, adds, removes, or replaces content.
2. Decide the destination for eachLink to this section
Reference rules:
| Event | Focus goes to | Announce |
|---|---|---|
| Open modal dialog | First meaningful element: the first input for forms; the dialog heading/container for content; the least destructive button for destructive confirmations | Dialog role + label (automatic when labeled) |
| Close dialog (cancel/Escape/close) | The element that opened it | — |
| Close dialog after action | The element that opened it, or the result of the action if the trigger no longer exists (e.g. new item row) | Result via live region if not visible from focus |
| Open drawer / non-modal panel | Panel heading or first control if the user opened it to act; leave focus if it opens passively | — |
| Open menu / listbox | First item (or selected item) | — |
| Close menu by selecting | The menu button (or the control whose value changed) | — |
| Delete item in a list | Next item → previous item → list heading or empty state's action | "'Q3 plan' deleted" + undo reachable |
| Add item | The new item (or its first field if it needs input) — unless adding many in succession, then keep focus in the add field | "Item added" |
| Reorder item (keyboard) | Stays on the moved item | New position: "Moved to position 3 of 8" |
| Filter / search results update | Stays in the search field | Result count: "12 results" (debounced) |
| Load more | First newly loaded item | "20 more items loaded" |
| Infinite scroll | Unchanged; provide a "load more" button or skip link as keyboard alternative | — |
| Route change (SPA) | Page <h1> (with tabindex="-1") or main landmark; skip for in-page tab/query changes | New page title |
| Form submit — validation errors | Error summary at top (with links to fields), or first invalid field for short forms | Error count / first error |
| Form submit — success, same page | Success message container, or the next logical control | Success message |
| Form submit — success, navigates | Follow route change rule; show confirmation on new page | Confirmation |
| Multi-step form next/back | Heading of the new step | "Step 2 of 4: Address" |
| Inline edit — enter | The input, with text selected if replacing is likely | — |
| Inline edit — save / cancel | The element that entered edit mode (the displayed value or edit button) | "Saved" on save |
| Expand / collapse (disclosure) | Stays on the toggle | State via aria-expanded |
| Tab widget | Arrow keys move between tabs; Tab moves into the panel | — |
| Toast / notification appears | Unchanged — never steal focus | Message via role="status" (or alert for errors); actions reachable via a known shortcut or landmark |
| Async content replaces focused element (e.g. skeleton → content) | Equivalent element in the new content | — |
| Session expiry / re-auth dialog | Dialog; after re-auth, return to prior element with state intact | — |
3. Handle "the return target is gone"Link to this section
The element that opened a dialog may no longer exist (it was the delete button of a row you just deleted). Define a fallback chain per case: next sibling → previous sibling → container heading → main heading. Never let it fall to <body>.
4. Contain focus where requiredLink to this section
- Modal dialogs: focus cannot leave until closed; background is inert (
inertattribute or nativeshowModal()); Escape closes unless the dialog guards unsaved input. - Non-modal panels/popovers: focus can leave; Escape closes and returns focus; clicking outside closes without stealing focus from the clicked element.
- Nested dialogs: avoid; if unavoidable, each level returns focus to its own trigger.
5. Composite widgets use roving focusLink to this section
Toolbars, menus, tab lists, listboxes, grids, and radio groups are a single Tab stop; arrow keys move within. Track the active item with roving tabindex or aria-activedescendant.
6. VerifyLink to this section
Walk every event with keyboard only, then with a screen reader (VoiceOver, NVDA). At each step: Where is focus? Can I see it? Did I hear what changed?
ChecklistLink to this section
- Every open/close/add/delete/replace event has a focus destination
- Dialogs return focus to their trigger, with a fallback if the trigger is gone
- Modal dialogs contain focus and make the background inert
- Destructive confirmations do not focus the destructive button first
- Deleting an item moves focus to a neighbor or logical fallback
- SPA route changes move focus to the page heading/main and announce the title
- Form errors move focus to a summary or first invalid field
- Toasts and background updates never steal focus, but are announced
- Composite widgets use arrow-key navigation with a single Tab stop
- Focus indicator visible on every focus destination (including
tabindex="-1"headings) - No positive
tabindex; DOM order matches visual order - Verified with keyboard and at least one screen reader
Common MistakesLink to this section
- Custom modal built from
divs without containment; Tab walks into the page behind. - Closing a dialog leaves focus on
<body>, so the next Tab starts at the top of the page. - Deleting a row removes the focused button; focus is lost.
- SPA navigation with no focus move, so screen reader users hear nothing and are still at the old link.
- Autofocus on page load into a search field, skipping the page content and hijacking mobile keyboards.
- Toasts with
role="alert"for routine success, interrupting whatever the screen reader is reading. outline: nonewith no replacement.- Live region added to the DOM at the same time as its message, so nothing is announced (the region must exist before content changes).
ExampleLink to this section
Focus map: task list with inline delete and undo
| # | Event | Focus before | Focus after | Announcement |
|---|---|---|---|---|
| 1 | Press Delete on task 3 (R2, no confirm) | task 3 "Delete" button | task 4's title link (task 3 gone) | "Task 'Draft brief' deleted. Undo available, press Ctrl+Z" |
| 2 | Delete last task in list | task 8 "Delete" | task 7's title link | same pattern |
| 3 | Delete only remaining task | task 1 "Delete" | empty state's Add task button | "Task deleted. No tasks left." |
| 4 | Activate Undo in toast | toast Undo button | restored task's title link | "Task restored" |
| 5 | Press Ctrl+Z (shortcut) | anywhere in list | restored task's title link | "Task restored" |
Implementation NotesLink to this section
- Native
<dialog>+showModal()provides focus containment, inert background, and Escape; you still set initial focus and return focus deliberately where the default is wrong. - The
inertattribute makes background content unfocusable and hidden from assistive tech for custom overlays. - Store the trigger (
document.activeElement) before opening; restore after closing, checkingisConnected. - For headings that receive programmatic focus:
tabindex="-1"and a visible:focus-visiblestyle. - Keep a single persistent polite live region (and one assertive) mounted at the app root; write messages into it.
- Framework routers: add a route-change hook that focuses the new
<h1>and updatesdocument.title. - After async DOM replacement, focus in
requestAnimationFrameor after the framework commits, not synchronously before the element exists.
Output ExpectationsLink to this section
Produce:
- Focus event list for the feature.
- Focus map —
Event | Focus before | Focus after | Fallback | Announcement. - Containment rules for each overlay.
- Live region plan — which messages, polite vs assertive.
- Findings — when reviewing, each lost/misplaced focus case with reproduction steps and file/line.