cf.completefrontend
My progress
FUNDAMENTALS · MODULE 12

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.

Two windows of a to-do list right after the user deleted Pay rent with the keyboard. Left, without focus management: no element has focus, focus is on the page body, the screen reader is silent, and the next Tab may land on the skip link at the top. Right, with focus management: focus is on the Delete button of the next task, Call Asha, and the screen reader says Pay rent deleted.Without focus managementSkip linkBuy milkDeleteCall AshaDeletefocus: bodyreader: silentWith focus managementSkip linkBuy milkDeleteCall AshaDeletefocus: Delete Call Ashareader: Pay rent deleted
Same delete, two outcomes. On the left the user's place is gone; on the right they are one keystroke from the next task, and they heard that the delete worked.

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.

Move focus when the user's context changes or their focused element is gone; announce when content changes around them.
EventKindMove focus?Announce?
Route change after a link clickContext changeYes, to the new page's heading or main areaYes, the new page title
Modal opensContext changeYes, into the dialogThe dialog's name, read automatically on focus
Modal closesContext change backYes, to the openerThe opener, read automatically on focus
Item deleted from a listContent change caused by the userYes, because the focused node is goneOften: "Task deleted"
Search results arriveContent changeNoYes, "24 results" in a status region
Toast after a saveContent changeNoYes, politely
Form submitted with errorsThe user is blockedYes, to the error summary or first bad fieldRead 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".

A state machine of where focus is. In the middle, focus is on a control such as a link or button. Opening a dialog moves focus inside the dialog, where Tab stays; closing it moves focus back to the control. A route change moves focus to the new page's h1, which has tabindex minus 1. A handled delete moves focus to the same control on the next item. An unhandled delete moves focus to document.body, a dead end where focus is lost.Focused controla link or buttonInside dialogTab stays inNew page h1tabindex=-1Next itemsame controldocument.bodyfocus lostopen dialogcloseroute changedelete, handleddelete, not handled
Every transition that removes or hides the focused control needs a planned target. The red box is where focus goes when nobody planned one: 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.

html
<!-- 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" is the key to focus management: it makes an element a valid focus() target without adding a Tab stop.
Six lines of markup and how focus treats each. A button: in the Tab order and focus works. An h1 with tabindex minus 1: not in the Tab order, but focus from script works, which makes it a focus target. A plain h1: focus calls are ignored. An h1 with tabindex 0: an extra Tab stop, avoid it. A disabled button: not reachable and focus is ignored. A button inside an inert div: not reachable, not focusable, not read by screen readers.markupwhat focus does with it<button>Save</button>Tab: yes focus(): yes<h1 tabindex="-1">Tab: no focus(): yes<h1>Billing</h1>Tab: no focus(): ignored<h1 tabindex="0">extra Tab stop: avoid<button disabled>Tab: no focus(): ignored<div inert> <button>Tab: no focus(): no read: no
The second line is the workhorse of focus management: 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.

ts
// 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 });
}
A small focus-loss detector. Run it in development and every delete, collapse, or re-render that drops focus shows up in the console. Clicking empty page space also triggers it; ignore those.

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.

A sequence diagram with four lifelines: the user, the router, the page, and the screen reader. The user clicks the Billing link. The router calls pushState and renders the new page, then sets document.title to Billing. After the render, the router focuses the page's h1. The focus event reaches the screen reader, which tells the user: Billing, heading level 1.UserRouterPageScreen readerclicks BillingpushState, renderset titleh1.focus()focus eventBilling, heading 1
The browser does none of this on 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

  1. Update document.title to 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.
  2. 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).
  3. Move focus to a landing spot at the top of the new content: usually the page's h1 with tabindex="-1", or the main element with tabindex="-1". Pass preventScroll: true if the router manages scroll.
  4. Optionally, announce "Navigated to Billing" in a polite live region.
  5. 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.
tsx
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>;
}
A heading that takes focus whenever the path changes, but not on the first page load. Every page renders one, so the first-load flag lives outside the component.

Decision Where should focus go after a client-side route change?

Focus the new page's h1

Give each page's h1 tabindex="-1".

Focus it after the route commits.

Pros

  • The screen reader reads the heading, which confirms where the user is.
  • The next Tab goes into the new content, not back through the header.

Cons

  • Every page needs one h1, rendered early (not behind a spinner).
  • Skips the page's nav, so users who want the sidebar must Shift+Tab or use landmarks.

Focus a wrapper (main or a route container)

Put tabindex="-1" on main or a div that wraps the routed content.

Focus it after each navigation.

Pros

  • One place in the layout, no per-page work.
  • Works even when pages forget their h1.

Cons

  • Screen readers may read the entire contents of the wrapper, which is long and noisy.
  • Browsers may draw a focus ring around the whole content area.

Announce only, keep focus where it is

Put the new title into a visually hidden aria-live="polite" region.

Leave focus on the link (or let it fall if the link is gone).

Pros

  • No focus ring surprises; sidebar users can keep moving through links.
  • What Next.js does by default.

Cons

  • Keyboard users who do not use a screen reader get no help; if the link was removed, focus is on the body.
  • The user must still Tab through everything to reach the new content.

Reset to the top, like a page load

Focus a skip link or a hidden element at the very top of the body.

The next Tab hits the skip link, as on a fresh page.

Pros

  • Feels like a normal page load; predictable.
  • The skip link gets the user to the content in one keystroke.

Cons

  • Nothing is announced unless you add an announcement.
  • An extra keystroke on every navigation, and in Gatsby's user tests (below) starting over at the top felt overwhelming.

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.

The user clicked
After the route change, the app will
The Billing page after a client-side navigation. Focus is on the Billing link in the sidebar, which survived the render./billingHomeBillingTeamh1: BillingDownload invoicefocus: on the page
The highlighted box is where focus is. If nothing is highlighted, focus fell to the page body.

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:

DialogInitial focusWhy
A form ("Rename file")The first fieldThe user came to type; save them a Tab.
A destructive confirm ("Delete project?")The Cancel buttonAn 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 buttonThere 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.

html
<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>
A native modal. autofocus picks the first stop; form method="dialog" closes it; the browser restores focus to the opener.

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.

Two layers. On top, the top layer holds the open dialog, Rename file, where focus is, and under it the backdrop, which dims the page. Below, the rest of the document, its header, list, and sidebar, is inert while the dialog is open: no focus, no clicks, not read by screen readers.Top layerdialog: Rename file::backdropfocus heredims pageDocument: inert while openno focusnot read
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?

Native dialog with showModal()

Render a dialog element and call showModal() from an effect.

Style ::backdrop for the dim layer.

Pros

  • Inert background, top layer, Escape, and focus return all built in.
  • Works with screen reader virtual cursors, not just Tab.

Cons

  • Animating open and close needs newer CSS (@starting-style, transition-behavior).
  • Some design systems wrap it awkwardly in portals.

Inert on everything else

Render the dialog in a portal as a sibling of the app root.

Set inert on the app root while the dialog is open; remove it on close.

Pros

  • One attribute blocks focus, clicks, and the accessibility tree behind the dialog.
  • Works with any custom dialog markup and with nested overlays.

Cons

  • Only works if the dialog is not inside the subtree you make inert.
  • You still write initial focus, Escape, and focus return yourself.

Tab-key loop (classic focus trap)

Listen for Tab on the dialog; on the last focusable element wrap to the first, and the reverse for Shift+Tab.

Often with invisible sentinel elements at both ends.

Pros

  • Works in very old browsers.
  • Libraries such as focus-trap package it.

Cons

  • Only stops Tab: a screen reader's virtual cursor can still read and activate content behind, unless you also hide it with aria-hidden or inert.
  • Breaks easily with iframes, shadow DOM, and content added while open.

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

A project page with a Rename button and two files. Stepping through: focus on Rename, the dialog opens with focus on the Name input and the page inert, Tab moves through the dialog and the browser toolbar, Escape closes it, and focus returns to Rename.Project pageRenamereport.pdfnotes.txtfocus: Rename buttonreader: “Rename, button”
Focus is on the Rename button. The user presses Enter, and the app calls 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.

A DOM tree. The body has two children: the app root, which has the inert attribute, and a dialog rendered in a portal next to it. The app root's children, header, main, and nav, are all inert: no focus, no clicks, not read. The dialog's children, an input and a Save button, stay focusable.body#appdialogheadermainnavinputSaveinertwhole subtree: no focus, not readstill focusable
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

Questionhidden or display noneinertaria-hidden=true
Visible on screen?NoYes (style it yourself)Yes
Focusable by Tab or script?NoNoYes, which is a bug if anything inside is focusable
Clickable?NoNoYes
Read by screen readers?NoNoNo
Typical useClosed menus, inactive tabsPage behind a modal, closed off-canvas drawer still on screen, a form while it savesDecorative 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 inert on the app root while the overlay is open, as showModal() does natively.
  • Off-screen carousel slides: they are still in the DOM and reachable by Tab; inert on them keeps Tab inside the visible slide.
  • A side navigation that slides away but stays rendered for the animation: inert while closed, so Tab does not walk through invisible links.
  • A form while it submits: inert on 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 inert as 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 happensExampleFix
The focused element is deletedDelete button in a list rowMove focus to a neighbor before or right after the removal
It is swapped for another elementEdit button becomes an inline text field; Load more becomes a spinnerFocus the replacement (the field), or keep the same button and change its label
It is remounted with a new keyA list re-sorted with key={index} or a key that changes on saveUse stable keys (the item's id) so the node survives
It becomes disabledSubmit button gets disabled while the request runsUse aria-disabled="true" and ignore clicks, so it stays focusable
It scrolls out of a virtualized listArrowing down a long grid unmounts the focused rowKeep the focused row mounted even when off-screen
Its container is hidden or made inertClosing a drawer while focus is inside itMove 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.

A timeline in five moments: keydown, the event handler, the render, the effect after the render, and the next key. Not handled: Enter, the handler removes the row, the commit removes the focused button, focus is on the body, and the next Tab goes somewhere unpredictable. Handled: Enter, the handler picks the next item before removing, the commit, the effect focuses the next item, and the next Tab continues from there.keydownhandlerrendereffectnext keynot handledEnterremovecommitbodyTab: ?handledEnterpick nextcommitfocus itTab: nexttime
The two lanes differ in two places only: the handler picks a target while it still knows the position, and an effect focuses it after the commit.

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.

  1. If there is a next item, focus the same control in the next item.
  2. If the deleted item was the last one, focus the same control in the previous item.
  3. 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.
  4. 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.
A flowchart for focus after a delete. Is there a next item? If yes, focus the same control on the next item. If no, is there a previous item? If yes, focus the same control on the previous item. If no, focus the list heading or the empty state, which has tabindex minus 1. In every case, announce the result, such as Pay rent deleted.Is there a next item?Focus next itemsame controlIs there a previous one?Focus previous itemsame controlFocus list headingor empty state, tabindex=-1yesnoyesnoin every case, announce:“Pay rent deleted”
Pick the target before you remove the item, while you still know where it was. Then announce, because landing on the next item does not confirm the delete.
tsx
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]);
Pick the target from the old list, before removing, then focus it after the render. The ref map gives you each row's button by id.

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.

After a delete, move focus to

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.

html
<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" -->
A status region is polite: it waits until the screen reader finishes the current speech. It must exist before you put text in it.

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 with aria-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.
A wireframe of a checkout form after a failed submit. At the top, an error summary titled There is a problem, with two links: Enter your email address, and Enter a postcode. Focus lands on the summary, which has tabindex minus 1, and each link jumps to its field. Below, the Email and Postcode fields are marked invalid, each with its error message under it.Checkout: 2 errorsThere is a problemEnter your email addressEnter a postcodefocus lands herelinks jump to fieldsEmailEnter your email addressaria-invalid=truePostcodearia-describedbyEnter a postcode
Focus moves once, on submit, to the summary. Its links take the user to each field, and each field carries 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?

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, and Menu handle initial focus, the trap or inert, Escape, and focus return. Product code never writes a trap.
  • List and collection components own deletes: a useFocusAfterRemove hook or a focusTarget prop 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.

ts
// 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();
Assert where focus is after the action, and what was announced. Both lines are cheap, and they catch nearly every regression.

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 dialog or inert on 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

LevelWhat a strong answer includes
JuniorFocus goes into a modal and back to the button on close; Escape closes it; do not remove focus outlines.
MidThe four moments (route, overlay, removal, new content); tabindex="-1" targets; native dialog and inert; next/previous/empty after delete; live regions for async updates.
SeniorWhere 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?

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?

Question 2

Walk me through focus in a modal dialog.

Question 3

A user deletes a row in a table with the keyboard. What should happen to focus?

Question 4

Search results load as the user types. Do you move focus to the results?

Question 5

What is the inert attribute and when would you use it?

Practice it

These design problems lean on focus management.

References

Read next