Focus management
Deciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
The problem: focus that falls off the page
Picture a keyboard user in a to-do app. They Tab to the third task's Delete button and press Enter, and the task disappears, along with the button they were on. Their screen reader says nothing. In some browsers the next Tab starts again from the skip link at the top, so they Tab through the header, the navigation, and the first two tasks just to get back.
Nothing crashed, and a mouse user never noticed. But anyone who moves with the keyboard, or listens with a screen reader, just lost their place. This is one of the most common accessibility bugs in single-page apps, and the fix is usually a few lines of code, not a new feature.
What focus is, in one paragraph
Focus is the one element on the page that receives keyboard input: typing goes into the focused field, Enter activates the focused button, and Tab moves to the next focusable element. A screen reader reads the focused element out loud when focus lands on it, so focus is both the user's cursor and their reading position. There is exactly one focused element at a time, and document.activeElement tells you which one.
When the focused element is removed, the browser does not pick a sensible neighbor. Focus falls back to document.body, which is what document.activeElement now returns. Browsers keep a rough "starting point" near the removed node, so the next Tab sometimes continues nearby and sometimes starts from the top; they do not agree. A screen reader announces nothing when focus lands on the body, and its reading cursor may reset to the top of the page.
The four moments that break focus
In a traditional multi-page site, the browser handles most of this. Clicking a link loads a new document, focus starts at the top, and the screen reader reads the new page title. A single-page app (SPA) takes that job away from the browser: it swaps DOM nodes itself, so it must also decide where focus goes. Four moments cause nearly every problem:
- Route change. The URL and most of the page change, but the browser did not load a new document, so focus stays on the link that was clicked, which may no longer exist.
- Modal open and close. A dialog appears on top of the page. Focus must go into it, stay in it, and come back to the button that opened it when it closes.
- Removing what has focus. A delete, a collapse, a re-render with a new
key, or a button swapped for a spinner. Focus drops to the body. - New content arriving. Search results load, a toast appears, a form shows errors. Here the right answer is often to not move focus at all, but to announce the change.
This lesson takes each moment in turn: what the browser does on its own, what you should do, and how to test it. It assumes you know tab order, tabindex, :focus-visible, and skip links from the keyboard navigation lesson, and that you build on native elements as the semantic HTML lesson describes.
The mental model: focus is a bookmark you carry for the user
Think of focus as a bookmark in a book the user reads one line at a time. A mouse user can look and click anywhere, so they barely need it. A keyboard user moves one step at a time from the bookmark, and a screen reader user hears only what is at the bookmark and after it. If you tear out the page the bookmark was on, you have to put the bookmark somewhere sensible for them.
That gives two duties. First, never lose the bookmark: whenever your code removes or hides the focused element, it places focus somewhere on purpose. Second, do not move the bookmark without a reason: yanking focus on every update loses the user's place just as badly and cuts off the screen reader mid-sentence.
Context change versus content change
The line between the two duties is the difference between a context change and a content change. A context change moves the user's task somewhere new: a new page, a dialog that blocks the page, a wizard step. A content change updates things around the user while they stay on the same task: results load, a toast slides in, a chat message arrives.
| Event | Kind | Move focus? | Announce? |
|---|---|---|---|
| Route change after a link click | Context change | Yes, to the new page's heading or main area | Yes, the new page title |
| Modal opens | Context change | Yes, into the dialog | The dialog's name, read automatically on focus |
| Modal closes | Context change back | Yes, to the opener | The opener, read automatically on focus |
| Item deleted from a list | Content change caused by the user | Yes, because the focused node is gone | Often: "Task deleted" |
| Search results arrive | Content change | No | Yes, "24 results" in a status region |
| Toast after a save | Content change | No | Yes, politely |
| Form submitted with errors | The user is blocked | Yes, to the error summary or first bad field | Read on focus |
Notice the delete row. Deleting is a content change, but it destroys the element that holds focus, so you are forced to move focus anyway. The rule is not "never move focus on content changes" but "never leave focus homeless, and never move it just to get attention".
document.body.Who decides where focus goes
The browser makes some focus moves for you: a click focuses a button (except in Safari on macOS), Tab follows the DOM order, autofocus works on page load and when a dialog opens, and showModal() moves focus into a dialog and back out. Everything else is your code. In a React or Vue app, focus is part of your state transitions: the reducer case for "delete item" also decides where focus goes.
It helps to treat focus as a small state machine over the regions of your UI: the page, a dialog, a menu, a list. Each transition (open, close, delete, navigate) has an entry point for focus and, for overlays, a return point. The figure above is that machine for a typical app, and writing it down in design reviews catches most focus bugs before any code exists.
Check your understanding · Exercise 1
Which of these events should move keyboard focus? Select all that apply.
The tools: the handful of APIs that move focus
Focus management needs very little API: element.focus(), tabindex="-1", document.activeElement, the focusin and focusout events, and the inert attribute. Each has a trap or two worth knowing before you write any code.
element.focus() and its options
el.focus() moves focus to el if it is focusable, and does nothing (silently) if it is not. Focusable means a native interactive element that is not disabled (button, a with href, input, select, textarea, summary), or any element with a tabindex attribute. A div or h1 without tabindex ignores focus() calls, which is the most common reason a focus fix "does nothing".
focus() scrolls the element into view by default. Pass { preventScroll: true } when you handle scroll yourself, for example when a router restores the scroll position. A newer focusVisible option asks the browser to show the focus ring, but support is still uneven, so do not rely on it.
<!-- In the Tab order, focusable by script: interactive things -->
<button>Save</button>
<!-- Not in the Tab order, but focusable by script: focus targets -->
<h1 tabindex="-1" id="page-title">Billing</h1>
<div role="alert" tabindex="-1" id="error-summary">...</div>
<!-- Never: puts a non-interactive element in the Tab order -->
<h1 tabindex="0">Billing</h1>
<!-- Never: positive values reorder the whole page -->
<input tabindex="3">
tabindex="-1" makes headings, lists, and summaries valid targets for focus() without adding Tab stops.tabindex="-1": a landing spot, not a stop
Most focus targets in this lesson are not interactive: a page heading, a list, an error summary. You make them focusable with tabindex="-1", which allows focus() from script but keeps the element out of the Tab sequence. When focus lands on it, a screen reader reads its name and role ("Billing, heading level 1"), and the next Tab goes to the next interactive element after it, which is exactly the "put the bookmark here" behavior we want.
One side effect: the browser may draw a focus ring on the heading. Under :focus-visible rules, browsers usually show the ring after a keyboard action, and that is the right default, because a keyboard user benefits from seeing where they landed. If designers object, a subtle ring on headings is better than outline: none, which hides the user's position completely.
document.activeElement and focus events
document.activeElement is the currently focused element, or document.body when nothing is focused. Inside shadow DOM, it returns the shadow host, and you read host.shadowRoot.activeElement to go deeper. To follow focus, listen to focusin and focusout: unlike focus and blur, they bubble, so one listener on a container sees every move inside it.
The focusout event has relatedTarget, the element receiving focus next; null means focus is leaving to nowhere (or to another window). Do not rely on events alone to spot focus loss, though. When the focused element is removed, the HTML spec moves focus to the body without firing blur or focusout, and browsers differ here, so a detector also has to watch for DOM changes.
// Dev-only: warn whenever focus falls to <body>, with the element that lost it.
if (process.env.NODE_ENV !== "production") {
let last: Element | null = null;
const check = () => {
if (last && document.activeElement === document.body && document.hasFocus()) {
console.warn("Focus lost to <body> from", last);
last = null;
}
};
document.addEventListener("focusin", (event) => { last = event.target as Element; });
// Disabling or hiding the focused element fires focusout; check once the update is done.
document.addEventListener("focusout", () => setTimeout(check));
// Removing it may fire no event at all, so also check after DOM changes.
new MutationObserver(() => setTimeout(check)).observe(document.body, { childList: true, subtree: true });
}
Check your understanding · Exercise 2
After a route change, your code calls document.querySelector("h1").focus(), but focus stays on the old link. What could cause it? Select all that apply.
Route changes in a single-page app
On a classic multi-page site, clicking a link unloads the page. The new document starts with focus at the top, and the screen reader announces the new page title and often starts reading. Users rely on that: the announcement is their proof that the click worked.
An SPA intercepts the click, calls history.pushState, and re-renders, so the browser sees no navigation and does nothing. If the clicked link survives the re-render (say, it lives in a sidebar), focus stays on it and the screen reader stays silent: the user does not know the page changed. If the link was inside the old page's content, it is removed and focus drops to the body.
pushState. The focus call is what makes the screen reader speak, and it must come after the new page is in the DOM.What a good route change does, step by step
- Update
document.titleto the new page's title, before or together with the render. The title is what users hear when they switch tabs and what announcers read. - Render the new page and wait until it is in the DOM (after the commit in React, after the route's loader data is there, not on the skeleton).
- Move focus to a landing spot at the top of the new content: usually the page's
h1withtabindex="-1", or themainelement withtabindex="-1". PasspreventScroll: trueif the router manages scroll. - Optionally, announce "Navigated to Billing" in a polite live region.
- Skip all of this on the first page load, and when only a query parameter changed because of a filter or a search box. Those are not context changes.
let firstLoad = true; // module scope: each page mounts its own PageHeading
function PageHeading({ children }: { children: string }) {
const ref = useRef<HTMLHeadingElement>(null);
const { pathname } = useLocation();
useEffect(() => {
document.title = `${children} | Acme`;
}, [children]);
useEffect(() => {
if (firstLoad) { firstLoad = false; return; } // not on the first page load
ref.current?.focus({ preventScroll: true });
}, [pathname]);
return <h1 ref={ref} tabIndex={-1}>{children}</h1>;
}
Decision Where should focus go after a client-side route change?
What we pick: For most apps: update the title, focus the new page's h1 (with tabindex="-1" and preventScroll if the router restores scroll), and optionally announce. Keep the wrapper approach as a fallback for pages without a heading. For in-page navigation such as changing a tab inside a settings page, keep focus on the control that caused it.
What frameworks do for you
Framework defaults differ and have changed over the years, so check the version you use and test it with a screen reader. Next.js ships a route announcer: a visually hidden live region that reads the new page's name after a client navigation, taken from document.title, else the first h1, else the URL path. It does not move focus. React Router leaves focus to you (its scroll restoration handles scroll, not focus), and so do Angular's and Vue's routers.
Reach Router, one of React Router's ancestors, focused the routed content by default; that default did not survive the merge into React Router 6. Gatsby, which used Reach Router then, tested route change techniques with users with disabilities in 2019. Screen reader users liked focus on the new page's heading best, found focus on a wrapper workable but subtle, and found a reset to the top of the app overwhelming.
A screen magnifier user in the same study got nothing from announcements and relied on visible focus outlines. So an announcer alone is a good floor, not the whole fix.
Focus is on: the Billing link in the sidebar, which survived the render
A screen reader says: nothing: the page changed silently
The next Tab goes to: the Team link, then the rest of the navigation before any content
Problem: Bad: a screen reader user does not know the click worked, and nobody is taken to the new content.
Modals: in, stay in, and back out
A modal dialog is the clearest context change on the web: it blocks the page until the user deals with it. Focus has three jobs: on open it goes in; while open it stays in, so Tab never reaches the page behind; on close it goes back to the opener. Get any one wrong and a keyboard user types into a page they cannot see, or starts over from the top after every dialog.
The modal dialog guide builds a complete dialog component, including the trap. Here we look at the rules, the platform features that now do most of the work, and the choices you still have to make.
Where focus goes on open
The right first stop depends on what the dialog is for. Pick the element the user most likely wants first, and that does no harm if they press Enter by accident:
| Dialog | Initial focus | Why |
|---|---|---|
| A form ("Rename file") | The first field | The user came to type; save them a Tab. |
| A destructive confirm ("Delete project?") | The Cancel button | An accidental Enter should not delete anything. |
| Long text first (terms, release notes) | The dialog's heading, with tabindex="-1" (and autofocus in a native dialog) | Focusing a button at the bottom would scroll past the text and the screen reader would skip it. |
| A simple message ("Upload complete") | The single action, or the close button | There is only one thing to do. |
Whatever you pick, the dialog needs an accessible name (aria-labelledby pointing at its heading), so that when focus enters, the screen reader announces "Rename file, dialog" before the field. That is how a screen reader user learns that a dialog opened at all.
The native dialog element does most of it
dialog.showModal() is supported in all current browsers and handles the hard parts. It puts the dialog in the top layer, above every z-index, and makes the rest of the document inert, so nothing behind can be clicked, focused, or found by a screen reader. Escape fires a cancel event and closes it, and on close the browser returns focus to the element that was focused before it opened.
On open, the browser runs the spec's "dialog focusing steps". Focus goes to the descendant with the autofocus attribute if there is one, else to the first element inside that Tab can reach, else to the dialog itself. Elements with tabindex="-1" are skipped by that search, so to start on a heading, give it autofocus as well.
<button id="rename-btn">Rename</button>
<dialog id="rename" aria-labelledby="rename-title">
<h2 id="rename-title">Rename file</h2>
<form method="dialog">
<label>Name <input name="name" autofocus></label>
<button value="cancel">Cancel</button>
<button value="ok">Save</button>
</form>
</dialog>
<script>
const dlg = document.getElementById("rename");
document.getElementById("rename-btn").onclick = () => dlg.showModal();
// On close, focus returns to #rename-btn automatically.
</script>
Two surprises. First, the native modal does not loop Tab the way custom traps do: after the last control, Tab can move into the browser's own toolbar, then back to the first control. That is intended, because the page behind stays unreachable.
Second, dialog.show() (without "Modal") opens a non-modal dialog: no inert background, no top layer, and focus is restored on close only if it is still inside the dialog. Use showModal() for anything that should block.
showModal() builds this whole stack for you. A custom modal has to rebuild it: a portal above the app, a backdrop, and inert on the app root.Decision How do you keep focus inside a custom modal?
What we pick: Use the native dialog with showModal() for new work. If your component library cannot, use inert on the app root plus your own initial focus and focus return. Keep the Tab-loop trap only as a legacy fallback, and never without hiding the background from assistive technology too.
Returning focus, and when the opener is gone
Native dialogs restore focus for you; custom ones must do it by hand. Save document.activeElement when opening, and focus it again after the dialog is removed. The catch is that the opener may be gone: a menu closes when its item is activated, and a "Delete row" confirm removes the row with its button. Check opener.isConnected before focusing, and have a fallback that fits the action:
- Opened from a menu: return to the menu's trigger button, not the vanished menu item.
- Confirm-and-delete: apply the delete rules from the chapter on removed elements (next row, previous row, or the list).
- Dialog that navigates ("Create project" then go to it): the route change rules apply; focus the new page's heading.
- Nested dialogs: keep a stack of openers, and pop one per close.
Focus through a native modal dialogStep 1 of 5: Before
dialog.showModal().Before: Focus is on the Rename button. The user presses Enter, and the app calls dialog.showModal().
Check your understanding · Exercise 3
A "Delete project?" dialog is built with a div, a Tab-key loop, and no other trap. What can still go wrong? Select all that apply.
inert: switching off a whole part of the page
The inert attribute is a boolean you can put on any element. While it is present, that element and everything inside it cannot be focused (by Tab or by script), clicked, selected, or found with find-in-page, and it is removed from the accessibility tree, so screen readers skip it. It is a one-word way to say "this part of the page is not available right now".
It reached Baseline in 2023, meaning it works in all major browsers: Chrome and Edge from version 102, Safari from 15.5, and Firefox from 112, the last to ship it in April 2023. Before that, people combined aria-hidden="true", tabindex="-1" on every focusable descendant, and a click-blocking overlay, and usually missed something.
inert switches off a whole subtree, and nothing inside can opt back in. That is why the dialog must be a sibling of the app root, never its child.inert versus hidden versus aria-hidden
| Question | hidden or display none | inert | aria-hidden=true |
|---|---|---|---|
| Visible on screen? | No | Yes (style it yourself) | Yes |
| Focusable by Tab or script? | No | No | Yes, which is a bug if anything inside is focusable |
| Clickable? | No | No | Yes |
| Read by screen readers? | No | No | No |
| Typical use | Closed menus, inactive tabs | Page behind a modal, closed off-canvas drawer still on screen, a form while it saves | Decorative icons and duplicated text |
The row to remember is the second one. aria-hidden hides content from screen readers but leaves it focusable, so a keyboard user can land on a button the screen reader pretends is not there and hear nothing. inert closes that gap because it removes both at once. If the content should also disappear visually, plain hidden is simpler and already does everything.
Where inert earns its place
- Behind a custom modal or drawer: set
inerton the app root while the overlay is open, asshowModal()does natively. - Off-screen carousel slides: they are still in the DOM and reachable by Tab;
inerton them keeps Tab inside the visible slide. - A side navigation that slides away but stays rendered for the animation:
inertwhile closed, so Tab does not walk through invisible links. - A form while it submits:
inerton the fieldset blocks edits mid-request while the values stay visible. Keep focus on (or move it to) something outside the inert part, such as a status message.
Mistakes people make with inert
- Making an ancestor of the dialog inert. Inert applies to the whole subtree, and a child cannot opt back out. Render overlays in a portal next to the app root, then make the app root inert.
- Making the focused element inert. If focus is inside a subtree when it becomes inert, focus falls to the body. Move focus first, then set
inert. - Forgetting the visual cue. Inert content looks normal, so users try to click it and nothing happens. Dim it, show a backdrop, or hide it.
- Old React versions. React 19 supports
inertas a normal boolean prop. React 18 and earlier did not know the attribute, so code set it through a ref or passed an empty string. Remove those workarounds when you upgrade.
Check your understanding · Exercise 4
A side drawer slides off-screen when closed but stays in the DOM for its animation. Keyboard users report Tab "disappearing" for a while. Which fixes work? Select all that apply.
When the focused element disappears
Back to the to-do app from the first chapter: the Delete button removed itself, and with it the user's focus. This is the biggest source of focus loss in modern apps, because declarative UI frameworks make it easy to remove or replace a node without noticing that it was focused.
Six common ways focus gets destroyed
| What happens | Example | Fix |
|---|---|---|
| The focused element is deleted | Delete button in a list row | Move focus to a neighbor before or right after the removal |
| It is swapped for another element | Edit button becomes an inline text field; Load more becomes a spinner | Focus the replacement (the field), or keep the same button and change its label |
| It is remounted with a new key | A list re-sorted with key={index} or a key that changes on save | Use stable keys (the item's id) so the node survives |
| It becomes disabled | Submit button gets disabled while the request runs | Use aria-disabled="true" and ignore clicks, so it stays focusable |
| It scrolls out of a virtualized list | Arrowing down a long grid unmounts the focused row | Keep the focused row mounted even when off-screen |
| Its container is hidden or made inert | Closing a drawer while focus is inside it | Move focus to the drawer's toggle first |
The disabled case surprises people. The HTML spec says that when the focused element stops being focusable, for example because it gets the disabled attribute, the browser moves focus to the body at its next rendering update. A screen reader user who just pressed Submit hears nothing and has lost their place. aria-disabled="true" keeps the button focusable and announced as dimmed; your click handler simply returns early while the request is in flight.
Where focus goes after a delete
There is no universal answer, but there is a sensible default that most design systems agree on: think about what the user most likely does next, which is often acting on the next item. So focus goes to the same control on the next item (its Delete button), or to the next item's main link if that is what the row is about.
- If there is a next item, focus the same control in the next item.
- If the deleted item was the last one, focus the same control in the previous item.
- If the list is now empty, focus the list's heading or the empty state message (with
tabindex="-1"), or the "Add item" control if that is the obvious next step. - In every case, announce the result in a polite status region: "Buy milk deleted". Focus landing on the next item reads that item, which is not proof that the delete worked.
const buttons = useRef(new Map<string, HTMLButtonElement>());
const [focusId, setFocusId] = useState<string | null>(null);
function remove(id: string) {
const i = items.findIndex((t) => t.id === id);
const target = items[i + 1] ?? items[i - 1] ?? null; // next, else previous
setItems(items.filter((t) => t.id !== id));
setFocusId(target ? target.id : "heading");
announce(`${items[i].title} deleted`);
}
useEffect(() => {
if (focusId === null) return;
const el = focusId === "heading" ? headingRef.current : buttons.current.get(focusId);
el?.focus();
setFocusId(null);
}, [focusId]);
Note the order: the target is chosen from the list before the item is removed, because afterwards you no longer know where the deleted item was. And the focus call happens after the render, in an effect, because before it the next item's button may still be the old DOM node or not exist at all.
Tasks (5 left)
- Buy milk
- Book the dentist
- Pay rent
- Call Asha
- Renew passport
Delete a task with the keyboard (Tab to a Delete button, press Enter) or the mouse, and watch where focus goes.
Undo, optimistic deletes, and rollbacks
Many apps delete optimistically and show "Deleted. Undo" in a toast for a few seconds. Do not move focus to the toast: it pulls the user out of the list, and focus is lost again when the toast disappears. Keep focus in the list, announce "Buy milk deleted", and keep the toast on screen while it has focus or hover.
If the server rejects the delete and the item comes back, put it back in place, announce the failure, and leave focus where the user is now. Do not jump them back to the restored row.
Check your understanding · Exercise 5
A list shows 3 invoices. The user deletes the 3rd (last) one with the keyboard. Which behaviors are good? Select all that apply.
New content, toasts, and form errors
The opposite mistake to losing focus is stealing it. When content arrives on its own, it is tempting to "help" by focusing it. That yanks a keyboard user away from their task and cuts a screen reader off mid-sentence. The default for content that changes around the user is: leave focus alone, and announce.
Results, feeds, and lazy panels
Search-as-you-type is the classic case. Results update below while the user types, so focus must stay in the box or the next keystroke goes nowhere. What they need is to know that results changed and how many there are, so you put "24 results for 'invoice'" into a status region, and they Tab or arrow into the results when ready.
<label for="q">Search</label>
<input id="q" type="search" aria-controls="results">
<!-- Present in the DOM from the start, empty, visually hidden -->
<div role="status" class="sr-only" id="results-status"></div>
<ul id="results">...</ul>
<!-- After each debounced fetch: -->
<!-- results-status.textContent = "24 results for invoice" -->
How live regions behave across screen readers, polite versus assertive, and why a region inserted together with its text is often not announced, are covered in the screen readers and live regions lesson. For focus management you need just one rule: when content changes and the user did not ask for a context change, reach for a live region, not focus().
Two cases sit in between. A Load more button is something the user activated: keep focus on the button if it stays (and announce "20 more items loaded"), or move focus to the first new item if the button disappears at the end of the list. Both are fine; leaving focus on the body is not.
The other is autoFocus on content that loads late, such as a field in a panel that renders after a fetch. React focuses such an element whenever it mounts, so it steals focus even if the user has moved on. Only autofocus elements that render together with a user action, such as a dialog the user opened.
Toasts
A toast is a content change by definition: "Saved", "Copied to clipboard", "Message sent". Render toasts inside a status region (role="status") so their text is announced, and never move focus to them. Urgent errors can use role="alert", which interrupts the screen reader, but use it rarely.
Toasts with an action ("Undo", "Retry") need more care, because a keyboard user has to reach the button before it disappears. Do not auto-dismiss toasts that have actions, or at least pause the timer while the toast has focus or hover, and offer the same action somewhere stable, such as a shortcut or the item's own menu. WCAG asks that time limits be adjustable, and a 5-second Undo button that keyboard users cannot reach fails that intent.
Form errors: the one content change where you do move focus
When a user submits a form and it fails validation, they are blocked: they cannot continue until they fix something. That is a context change, so focus should move to help them. There are two good patterns:
- Focus the first invalid field. Best for short forms. Each field has
aria-invalid="true"and its error message is linked witharia-describedby, so the screen reader reads "Email, invalid entry, Enter an email address like name@example.com". - Focus an error summary at the top. Best for long forms and multi-error cases. A box with a heading ("There is a problem"),
tabindex="-1", and a list of links, one per error, each pointing to its field. The GOV.UK Design System made this pattern well known; it moves focus to the summary when the page shows errors.
aria-invalid and its message via aria-describedby.Inline validation as the user leaves each field is different: they are moving through the form on purpose, so never pull focus back to a field that just failed. Mark it invalid, show the message, and let the summary or the submit handler deal with it later. And do not announce errors on every keystroke; "Password too short" after each character is noise.
Check your understanding · Exercise 6
Which of these should move focus? Select all that apply.
Think first
In a chat app, a new message arrives while the user is typing a reply. Should focus move to the new message? Should anything be announced?
Focus stays in the composer: the user is mid-sentence, and a focus move would send their next keystrokes somewhere else.
Announce new messages politely ("New message from Priya"), and let users limit announcements to mentions in busy channels. The message list can be a log region (role="log"), which screen readers treat as a stream of additions.
Making it a habit: architecture and testing
Focus bugs come back whenever a new feature adds a delete button or a new overlay. The teams that avoid them do not rely on every engineer remembering; they put focus rules into shared pieces of the app and into tests.
Where focus logic lives
- The router layer owns route changes: one component or hook updates the title, focuses the page heading, and announces. Pages only render a
PageHeading. - The design system owns overlays:
Dialog,Drawer,Popover, andMenuhandle initial focus, the trap orinert, Escape, and focus return. Product code never writes a trap. - List and collection components own deletes: a
useFocusAfterRemovehook or afocusTargetprop decides next, previous, or empty state. Product code only says what was deleted. - One announcer owns live regions: a single polite and a single assertive region near the root, with an
announce(text, politeness)function. Many scattered regions are harder to get right.
Libraries such as React Aria and Radix ship focus scopes, focus restore, and containment as primitives, and newer design systems build on the native dialog. A product team that uses them gets most of the modal behavior for free. The delete and route rules are more product-specific, and that is where most real-world bugs remain.
Testing focus
Focus is one of the easiest accessibility behaviors to test automatically, because the expected result is a single element. Scanners such as axe cannot find these bugs, because they check one moment and never perform a delete or open a dialog. You need behavior tests.
// Playwright: delete the last row, focus moves to the previous row's button.
test("focus after deleting the last invoice", async ({ page }) => {
await page.goto("/invoices");
const rows = page.getByRole("listitem");
await rows.nth(2).getByRole("button", { name: /delete/i }).click();
await expect(rows).toHaveCount(2);
await expect(rows.nth(1).getByRole("button", { name: /delete/i })).toBeFocused();
await expect(page.getByRole("status")).toHaveText(/deleted/);
});
// Testing Library (unit): dialog returns focus to its opener.
await user.click(screen.getByRole("button", { name: "Rename" }));
await user.keyboard("{Escape}");
expect(screen.getByRole("button", { name: "Rename" })).toHaveFocus();
Add the focus-loss detector from the tools chapter to development builds, and do a manual pass with a real screen reader (VoiceOver on macOS, NVDA on Windows) for each new overlay or list pattern. Unit test environments such as jsdom do not implement every focus rule; jsdom ignores inert, for example, so an inert button still accepts focus() there. Test overlays in a real browser with Playwright or similar.
Check your understanding · Exercise 7
Which checks would catch a bug where closing a drawer leaves focus on the body? Select all that apply.
Focus management in a system design interview
Focus rarely is the question, but it is one of the clearest ways to show senior-level care in any frontend design that has overlays, lists, or client routing. You do not need a separate section; weave one or two sentences into the parts of the design where it matters.
When to bring it up
- Designing a modal, drawer, or popover component: say initial focus, the trap (native
dialogorinerton the background), Escape, and focus return, including the case where the opener is gone. - Designing an SPA shell or router: mention that client navigation loses the browser's built-in focus reset, and how you restore it: update the title, focus the page heading, announce.
- Designing lists with actions (email, to-do, file manager): say where focus goes after delete, archive, or move, and that the status region announces the result.
- Designing search, feeds, chat, notifications: say that you will not steal focus, and that changes are announced politely.
- Designing forms: focus the error summary or first invalid field on submit, never during typing.
How deep to go by level
| Level | What a strong answer includes |
|---|---|
| Junior | Focus goes into a modal and back to the button on close; Escape closes it; do not remove focus outlines. |
| Mid | The four moments (route, overlay, removal, new content); tabindex="-1" targets; native dialog and inert; next/previous/empty after delete; live regions for async updates. |
| Senior | Where focus logic lives (router, design system, list primitives, one announcer); edge cases like a vanished opener, disabled submit buttons, virtualized rows, back navigation and scroll; how it is tested (Playwright focus assertions, dev focus-loss logging, screen reader passes). |
What to draw: if you have a whiteboard, the small state machine from the mental model chapter is enough. Put the focused control in the middle, the dialog, the new page, and the next item around it, and the red box for the body, then say which component owns each arrow. It takes a minute and shows that you treat the user's position as part of the state, not as polish.
Think first
You are designing an email client. The user has a message open in the reading pane and presses the Archive shortcut. Where does focus go?
Archiving removes the open message, so focus must move on purpose. Most email clients open the next message (newer or older, often a user setting) and focus its content or its row, so the user can keep triaging with the keyboard.
Announce "Conversation archived" in a status region, with Undo on a shortcut. If the folder is now empty, focus its heading or empty state. Make it a rule of the list component, not a one-off.
Summary
- Focus is the keyboard user's cursor and the screen reader user's reading position; there is exactly one focused element, and losing it costs them their place.
- Move focus only on context changes (route change, modal, blocked form) or when the focused element is removed; announce everything else with a live region.
- tabindex="-1" turns headings, lists, and summaries into focus targets without adding Tab stops, and focus() runs only after the target is rendered.
- After a client-side route change, update the title, focus the new page's h1 (with preventScroll when the router restores scroll), and optionally announce.
- Modals: focus in, stay in, back out. Prefer showModal(), which makes the background inert and restores focus; otherwise inert on the app root plus manual return, with a fallback when the opener is gone.
- After a delete, focus the same control on the next item, else the previous, else the list heading or empty state, and announce the result.
- Watch for hidden focus loss: disabled submit buttons, unstable keys, swapped elements, virtualized rows, and inert or hidden containers.
- Put focus rules in shared layers (router, design system, list primitives, one announcer) and test them with focus assertions, not just axe.
In the interview
Questions interviewers ask about this. Answer each out loud first, then compare.
Question 1
In a React SPA, how do you handle focus on route changes?
The browser does not reset focus on pushState navigation, so the app must. After the new route has rendered, I update document.title, then focus the page's h1, which has tabindex="-1" so it can take focus without becoming a Tab stop. I pass preventScroll: true so the router's scroll restoration still works on back and forward.
I skip it on the first page load and on changes that only update query parameters, such as filters. Optionally I announce "Navigated to Billing" in a polite live region. Frameworks differ: Next.js announces the new title but focus is still my call, and React Router leaves both to the app, so I would put this in one shared component and test it with a screen reader.
Question 2
Walk me through focus in a modal dialog.
On open, focus goes in: to the first field for a form, to Cancel for a destructive confirm, or to the heading for long text. The dialog has an accessible name so it is announced. While open, the background is unreachable: showModal() makes it inert natively; for a custom dialog I put it in a portal and set inert on the app root. A Tab loop alone is not enough because the screen reader cursor can escape it.
On close (Escape, Cancel, or Save), focus returns to the opener. Native dialogs do that; for custom ones I save document.activeElement on open and restore it, checking isConnected, with a fallback when the opener is gone, for example the menu trigger or the next row after a delete.
Question 3
A user deletes a row in a table with the keyboard. What should happen to focus?
Without code, the focused Delete button is removed and focus drops to the body, so the user loses their place. I pick the target before removing: the same control on the next row, or the previous row if it was the last, or the table's heading or empty state if nothing is left. I focus it after the render commits.
I also announce "Invoice 42 deleted" in a status region, because landing on the next row reads that row, which does not confirm the delete. If there is Undo, I keep it reachable by shortcut instead of focusing the toast.
Question 4
Search results load as the user types. Do you move focus to the results?
No. The user is typing, and moving focus would send their next keystrokes somewhere else. I keep focus in the input and announce the result count in a polite status region that already exists in the DOM, debounced so it does not speak on every keystroke.
Users reach the results with Tab or, in a combobox pattern, with the arrow keys via aria-activedescendant. The rule: move focus on context changes the user asked for, announce content changes.
Question 5
What is the inert attribute and when would you use it?
inert makes an element and its whole subtree unfocusable, unclickable, unselectable, and hidden from the accessibility tree, while staying visible. It has been Baseline since 2023. It replaces the old mix of aria-hidden, tabindex="-1" on every descendant, and click-blocking overlays.
I use it for the page behind a custom modal or drawer, for off-screen carousel slides, and for a closed but still-rendered side nav. Traps: never make an ancestor of the overlay inert, move focus out before making a subtree inert, and add a visual cue because inert content looks normal.
Practice it
These design problems lean on focus management.
- Design a tabs componentEasy
- Design a tooltipEasy
- Design a toast notification systemEasy
- Design a modal dialogEasy
- Design a pagination controlEasy
- Design an OTP inputEasy
- Design a signature padEasy
- Design a cookie consent managerEasy
- Design a custom select dropdownMedium
- Design a date pickerMedium
- Design a tag (multi-select) inputMedium
- Design a file-explorer tree viewMedium
- Design a command paletteMedium
- Design a drag-and-drop reorderable listMedium
- Design a multi-step form wizardMedium
- Design an avatar image cropperMedium
- Design an image lightbox with zoomMedium
- Design an Instagram-style stories viewerMedium
- Design a product tourMedium
- Design PinterestMedium
- Design a dropdown menuMedium
- Design an e-signature workflow like DocuSignMedium
- Design a virtualized listHard
- Design an embeddable code editorHard
- Design a secure payment formHard
- Design an embeddable third-party widgetHard
- Design LinkedInHard
- Design NetflixHard
- Design a customer support ticket dashboardHard
- Design a long insurance application formHard
References
- MDN: HTMLElement.focus() Options such as preventScroll, and which elements are focusable.
- MDN: inert global attribute What inert removes, and accessibility notes.
- MDN: the dialog element showModal(), autofocus, closing, and focus behavior.
- WAI-ARIA Authoring Practices: Dialog (Modal) pattern Initial focus choices and returning focus on close.
- web.dev: Focus Focus order, focus indicators, and managing focus in apps.
- GOV.UK Design System: Error summary The error summary pattern and when it takes focus.
- Next.js docs: Accessibility The built-in route announcer.
- Gatsby: What we learned from user testing of accessible client-side routing techniques Marcy Sutton's 2019 user tests of route change focus techniques.
Read next
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Semantic HTML firstUsing the HTML element that already means what you need, like
button,a,nav, andh2, so keyboards, screen readers, and voice control understand the page with no extra code. - ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Client-side routingHow single-page apps change screens without reloading: matching URLs to nested layouts, loading each route's code and data in parallel, guarding private pages, and restoring scroll and focus.