Topic 18.2
Managing Focus
In one line
Keyboard and screen reader users follow focus, so when React swaps content, changes the route or opens a modal, you must decide where focus goes: to the new page's heading, into the dialog, and back to the button that opened it.
Think of it like this
A tour guide in a museum. Visitors who can't see the map follow the guide's voice from room to room. If the guide walks into a new room without saying anything, or vanishes when a door closes, the group is lost. Focus is the guide for keyboard and screen reader users: the one element that receives key presses and that a screen reader reads. When your app changes the scene, focus has to lead people to it.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Focus
- Being the element that receives keyboard input. Only one element has it at a time.
- document.activeElement
- The element that currently has focus (
<body>when nothing does). - Focusable
- Able to receive focus. Buttons, links with
hrefand form fields are focusable by default. - tabIndex={-1}
- Lets code focus an element with
.focus()without putting it in the Tab order. - Focus trap
- Keeping Tab and Shift+Tab cycling inside a dialog while it is open.
- Inert
- An element (and everything inside it) that can't be focused, clicked or found by screen readers. Set with the
inertattribute. - Focus restoration
- Putting focus back on the element that opened a dialog or menu when it closes.
- Skip link
- A link at the top of the page that jumps keyboard users past repeated headers to the main content.
- Route announcer
- A live region a framework uses to tell screen readers that the page changed after client-side navigation.
Step by step
01Know where focus goes when content disappears
Delete the focused item in a list and focus falls to <body>. Decide where it should go instead: the next item, the previous one, or the list's heading. Do it right after the update, in the event handler with flushSync (Topic 8.2) or in an effect that reacts to the change.
import { flushSync } from "react-dom";
function remove(id, index) {
flushSync(() => setTodos((t) => t.filter((x) => x.id !== id)));
// the DOM is updated now: focus the item that took its place, or the heading
const buttons = listRef.current.querySelectorAll("button.delete");
(buttons[index] ?? buttons[index - 1] ?? headingRef.current).focus();
}02Move focus on route changes
Give each page's <h1> a ref and tabIndex={-1}. In an effect keyed on the pathname, focus it, but skip the very first render. Render a <title> so the tab title (and screen reader announcement on focus) matches the page. Style h1:focus with no outline if you like: the focus is for screen readers, not a visible control.
03Prefer the native dialog for modals
<dialog> with showModal() handles moving focus in, the inert background, Escape and focus return. Keep React state as the source of truth: an effect calls showModal() or close() when open changes, and onClose sets open back to false when the browser closes it (Escape).
04Custom modal: remember, trap, restore
If you must build your own (a design system's portal-based modal), the effect does the bookkeeping: store the opener, make the app inert, focus the first field; the cleanup undoes it and focuses the opener. An onKeyDown on the dialog wraps Tab at the edges and closes on Escape. Libraries such as Radix, React Aria and Headless UI ship this tested (Topic 18.4).
05Add a skip link
Put it first in the app. Hide it off-screen until it receives focus, then show it on top. Following a same-page link moves focus to the target in current browsers when the target is focusable, which is what tabIndex={-1} is for. With a hash-based router (HashRouter) the # belongs to the router, so focus the target from an onClick instead.
<a className="skip-link" href="#main">Skip to main content</a>
<Header />
<main id="main" tabIndex={-1}>
<Outlet />
</main>
/* CSS */
.skip-link { position: absolute; left: -9999px; }
.skip-link:focus { left: 8px; top: 8px; z-index: 100; background: white; padding: 8px; }Try it yourself
- 1
Remove the route focus
In the first example, delete the
headingRef.current.focus()line. Predict the second log line. (Focus stays on theACart link.) Now imagine you can't see the screen: nothing tells you the page changed. - 2
Take away data-autofocus
In the dialog example, remove
data-autofocusfrom the Cancel button. Predict where focus goes when the dialog opens. (The first focusable element inside the dialog: Delete. One Enter press would delete the account.) Then tryautoFocuson Cancel instead: focus still lands on Delete, because React never writes theautofocusattribute thatshowModal()looks for. - 3
Forget to restore
In the custom modal, delete
opener?.focus();from the cleanup. Predict the last log line. (Focus falls toBODY: the button that opened the modal is forgotten.)
Code & diagrams
Without the effect, focus would stay on the Cart link and a screen reader would say nothing about the new page. Try Tab from the top of the preview: the skip link appears first.
Preview
Console
Nothing logged yet.Expected console output
on load, focus is on: BODY
after navigating, focus is on: H1 Your cart
document.title: Your cart | Shop
after skip link: MAIN mainThe browser did the hard parts: focus moved inside (the effect then picked Cancel over the first button, Delete), the page behind refused focus, and closing put focus back on the opener. Open it yourself and press Escape: `onClose` keeps React's `open` state in step.
Preview
Console
Nothing logged yet.Expected console output
open: true | focus: Cancel
tried to focus the page behind, focus: Cancel
open: false | focus: openThe script presses keys by dispatching `keydown` events, the same way the exercises test you. Each of the four numbered steps is one line; forgetting any of them is a real accessibility bug. The portal (Topic 8.5) keeps the modal outside `#app`, so making `#app` inert doesn't hide the modal itself.
Preview
Console
Nothing logged yet.Expected console output
opened, focus: INPUT
Tab from Cancel wraps to: INPUT
Shift+Tab from the first field wraps to: Cancel
after Escape, focus: renameBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Unmount the focused element
In a step-by-step wizard, the focused Next button belongs to step 1. Clicking it renders step 2 in its place, with no focus code.
Break #2
Open a portal modal without moving focus
Render a role="dialog" div through createPortal and do nothing about focus.
Myth vs fact
Myth
Screen readers notice when a single-page app changes routes.
Fact
Nothing tells them: no page load happens. You (or the framework, like Next.js's route announcer) must move focus or announce the new page.
Myth
autoFocus is always bad.
Fact
It's bad on page load, where it skips content. In a newly revealed form it's exactly right, and React applies it when the element mounts. (For a <dialog> opened with showModal(), focus the element yourself after opening: React doesn't write the autofocus attribute the browser looks for.)
Myth
A focus trap means users can never leave.
Fact
A modal trap must always have an exit: Escape and a visible close button. Traps are only for modal content; never trap focus in a page region.
Myth
outline: none on focus is fine because it looks cleaner.
Fact
Removing the focus indicator leaves keyboard users blind. Restyle it with :focus-visible instead. Removing it is fine only on elements focused by code for screen readers, like a page <h1>.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
React handles
autoFocusitself: during the commit it calls.focus()on the mounted element, rather than relying on the browser's page-load-only behaviour, soautoFocusworks for elements that appear later (such as in a conditionally rendered form). The flip side: React doesn't put theautofocusattribute in the DOM, so browser features that read it (the dialog focusing steps ofshowModal()) don't see it. The element is focused once, at mount, while the dialog may still be closed. - ▸
The
focusevent in React isonFocusbut it's implemented with the bubblingfocusinevent, andonBlurwithfocusout. SoonBluron a container fires when focus leaves any child; checke.relatedTarget(where focus is going) withe.currentTarget.contains(e.relatedTarget)to close a popup only when focus leaves the whole widget. - ▸
<dialog>closing returns focus to the previously focused element in current browsers (a fairly recent HTML spec change), and the Popover API (popoverattribute) brings its own light-dismiss and focus-return behaviour for non-modal popups. Check your browser support matrix before dropping a custom fallback. - ▸
Moving focus has side effects: it scrolls the element into view and screen readers read it. Use
el.focus({ preventScroll: true })when you manage scroll yourself, and don't move focus on every small update; for status changes, announce with a live region instead (Topic 18.3).
Remember this
- 1
Focus is the state of being the element that gets keyboard input;
document.activeElementtells you which element has it. Tab moves focus forward through focusable elements (links withhref, buttons, form fields, elements withtabIndex={0}), Shift+Tab moves backward.tabIndex={-1}makes an element focusable from code only (el.focus()) without adding it to the Tab order. PositivetabIndexvalues (1, 2…) reorder the whole page and are almost always a mistake. - 2
React creates the problem in a way plain HTML pages don't. When the focused element is removed (a list item deleted, a step of a wizard replaced, a component unmounted), the browser drops focus to
<body>, and the next Tab starts from the top of the page. And a client-side route change swaps the page content without a page load, so screen readers don't hear a new page title and focus stays on the link that was clicked, possibly in a part of the page that no longer exists. Plain multi-page sites get both for free from the browser. - 3
Focus after a route change: the common pattern is to move focus to the new page's main
<h1>(giventabIndex={-1}so code can focus it) and updatedocument.title(React 19 lets any component render a<title>, which React places in the<head>). Do it in an effect that runs when the location changes, but not on the first load, where the browser's own behaviour is right. React Router doesn't move focus for you. Next.js includes a route announcer that announces the new page'sdocument.title(or<h1>, or path) to screen readers on client navigations. - 4
Modals: when a dialog opens, focus must move into it; while it's open, Tab must stay inside it (a focus trap) and the page behind must be unreachable; Escape closes it; when it closes, focus must return to the element that opened it. The native
<dialog>element opened withshowModal()does all of this in current browsers: it moves focus to the first focusable element (or the one with the HTMLautofocusattribute), makes the rest of the page inert, closes on Escape (firingcanceland thenclose), and puts focus back on the previously focused element when it closes. In React, sync it with a ref and an effect, and useonCloseto update state when the user presses Escape. One React trap: React handles theautoFocusprop itself and **doesn't write theautofocusattribute** to the DOM, soshowModal()can't see it. To start on a particular button (Cancel, for a destructive action), focus it yourself in the effect, right aftershowModal(). - 5
When you build a custom modal (for example with a portal, Topic 8.5), you do those steps yourself: remember
document.activeElementwhen opening, setinerton the app container (a real boolean prop in React 19), focus the first field, wrap Tab and Shift+Tab at the edges in anonKeyDown, close on Escape, and in the effect's cleanup give focus back to the opener. The cleanup runs when the modal unmounts, so focus restoration can't be forgotten. - 6
Skip links are the first focusable element on the page: a link such as "Skip to main content" (
href="#main") that is visually hidden until focused. It lets keyboard users jump over a long header and menu on every page. The target (<main id="main" tabIndex={-1}>) getstabIndex={-1}so focus can land on it. Refs are how React code reaches a DOM element to call.focus()(Topic 8.2);autoFocusmakes React focus an element when it mounts, which is right for a newly revealed form but wrong on page load, where it skips content and surprises screen reader users.
Explain it without notes
Why do single-page apps need explicit focus management on route changes, and how would you implement it?
List everything an accessible modal must do with focus, and say which parts <dialog> with showModal() gives you.
What happens to focus when React unmounts the focused element, and how do you handle it?
What is a skip link and why does its target need tabIndex={-1}?
Practice
Write a useFocusOnChange(ref, value) hook that focuses ref.current whenever value changes, but not on the first render.
Write the effect for a custom popover that remembers the opener when it opens and restores focus when it closes.
Trade-offs
- ↔
Focusing the
<h1>after navigation is predictable and works everywhere, but it reads only the heading. Announcing via a live region keeps focus at the top of the document instead. Teams pick one approach and apply it to every route. - ↔
Native
<dialog>saves code and gets browser fixes for free, but styling the backdrop and animating open/close is more limited than a fully custom modal. Headless libraries sit in between.
Done when you can
I can explain why SPAs lose focus on route changes and fix it with a ref and an effect.
I can build a modal with
<dialog>and keep React state in sync withonClose.I can write a custom focus trap with inert background and focus restoration.
I handle focus when the focused element is removed.
I can add a skip link and explain
tabIndex={-1}.