Command Palette

Search for a command to run...

Hectal
PHASE 18Intermediate ~28 min· topic 2 of 5

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 href and 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 inert attribute.
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.

TodoList.jsxwhole filejsx
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.

Move focus on route changesdiagram
Rendering diagram…

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).

Prefer the native dialog for modalsdiagram
Rendering diagram…

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.

Layout.jsxwhole filejsx
<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. 1

    Remove the route focus

    In the first example, delete the headingRef.current.focus() line. Predict the second log line. (Focus stays on the A Cart link.) Now imagine you can't see the screen: nothing tells you the page changed.

  2. 2

    Take away data-autofocus

    In the dialog example, remove data-autofocus from 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 try autoFocus on Cancel instead: focus still lands on Delete, because React never writes the autofocus attribute that showModal() looks for.

  3. 3

    Forget to restore

    In the custom modal, delete opener?.focus(); from the cleanup. Predict the last log line. (Focus falls to BODY: the button that opened the modal is forgotten.)

Code & diagrams

Focus the new page's heading after navigation React 19+ New tab

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

Press Run to see the page.

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 main
A native <dialog> moves, traps and restores focus New tab

The 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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

open: true | focus: Cancel
tried to focus the page behind, focus: Cancel
open: false | focus: open
A custom modal: remember, trap, restore New tab

The 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

Press Run to see the page.

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: rename

Break 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.

terminal
$ console.log(document.activeElement.tagName) after clicking Next
── what you'll see ──
BODY

Break #2

Open a portal modal without moving focus

Render a role="dialog" div through createPortal and do nothing about focus.

terminal
$ Keyboard
── what you'll see ──
Focus stays on the button behind the overlay. Tab walks through links hidden under the backdrop; the dialog, rendered at the end of <body>, is reached last, if at all.

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 autoFocus itself: during the commit it calls .focus() on the mounted element, rather than relying on the browser's page-load-only behaviour, so autoFocus works for elements that appear later (such as in a conditionally rendered form). The flip side: React doesn't put the autofocus attribute in the DOM, so browser features that read it (the dialog focusing steps of showModal()) don't see it. The element is focused once, at mount, while the dialog may still be closed.

  • ▸

    The focus event in React is onFocus but it's implemented with the bubbling focusin event, and onBlur with focusout. So onBlur on a container fires when focus leaves any child; check e.relatedTarget (where focus is going) with e.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 (popover attribute) 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. 1

    Focus is the state of being the element that gets keyboard input; document.activeElement tells you which element has it. Tab moves focus forward through focusable elements (links with href, buttons, form fields, elements with tabIndex={0}), Shift+Tab moves backward. tabIndex={-1} makes an element focusable from code only (el.focus()) without adding it to the Tab order. Positive tabIndex values (1, 2…) reorder the whole page and are almost always a mistake.

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

    Focus after a route change: the common pattern is to move focus to the new page's main <h1> (given tabIndex={-1} so code can focus it) and update document.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's document.title (or <h1>, or path) to screen readers on client navigations.

  4. 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 with showModal() does all of this in current browsers: it moves focus to the first focusable element (or the one with the HTML autofocus attribute), makes the rest of the page inert, closes on Escape (firing cancel and then close), and puts focus back on the previously focused element when it closes. In React, sync it with a ref and an effect, and use onClose to update state when the user presses Escape. One React trap: React handles the autoFocus prop itself and **doesn't write the autofocus attribute** to the DOM, so showModal() can't see it. To start on a particular button (Cancel, for a destructive action), focus it yourself in the effect, right after showModal().

  5. 5

    When you build a custom modal (for example with a portal, Topic 8.5), you do those steps yourself: remember document.activeElement when opening, set inert on the app container (a real boolean prop in React 19), focus the first field, wrap Tab and Shift+Tab at the edges in an onKeyDown, 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. 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}>) gets tabIndex={-1} so focus can land on it. Refs are how React code reaches a DOM element to call .focus() (Topic 8.2); autoFocus makes 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

01

Why do single-page apps need explicit focus management on route changes, and how would you implement it?

02

List everything an accessible modal must do with focus, and say which parts <dialog> with showModal() gives you.

03

What happens to focus when React unmounts the focused element, and how do you handle it?

04

What is a skip link and why does its target need tabIndex={-1}?

Practice

01

Write a useFocusOnChange(ref, value) hook that focuses ref.current whenever value changes, but not on the first render.

02

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 with onClose.

  • 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}.