Headline: The Next.js App Router announces client-side route changes to screen readers through its built-in route announcer, but it never moves keyboard focus. Focus is left on a link that React just unmounted, so the next Tab press restarts at the top of the document — and the fix is a fifteen-line client component plus one
tabIndex={-1}.
Key takeaways
- Next.js injects a route announcer on every client-side navigation: a visually hidden
aria-liveregion that reads the newdocument.title, falling back to the page'sh1. It handles announcing; it does not handle focus. - A client-side navigation in the Next.js App Router does not reset keyboard focus. When the activated link unmounts,
document.activeElementfalls back to<body>, so the user's next Tab starts from the top of the page instead of the new content. - The fix is a client component that watches
usePathname()fromnext/navigationand calls.focus({ preventScroll: true })on a container carryingtabIndex={-1}.tabIndex={-1}makes a non-interactive element focusable by script without adding it to the Tab order. -
inertis a global HTML attribute that removes a subtree from the Tab order, the accessibility tree, and pointer hit testing in one declaration.aria-hidden="true"removes it from the accessibility tree only and leaves every control inside it tabbable. - The native
<dialog>element traps focus and renders in the browser top layer only when opened imperatively withshowModal(). Rendering<dialog open>in JSX produces a non-modal dialog with no focus trap and no backdrop.
I found this the way most people find accessibility bugs: not with an audit tool, but by unplugging my mouse for an afternoon. Navigating my own dashboard with the keyboard alone, every link left me stranded — the page had changed, and Tab took me back to the site logo. Since the European Accessibility Act became applicable on 28 June 2025, that class of bug also stopped being purely a craft question for a lot of the products I work on.
What does the Next.js App Router already do for screen readers on navigation?
It announces the new page title, and that is all it does. Next.js renders a route announcer element into the DOM — a visually hidden region with aria-live — and writes the new page name into it on each client-side navigation. It reads document.title first and falls back to the text of the page's h1. A screen reader user therefore hears that something changed.
Two gaps are left for application code to close. The first is that the announcement is only as good as the title: a title resolved late by generateMetadata in a streamed segment can land after the announcer fires, so what gets read may be the previous page's name. A distinct, statically resolved title per route is the reliable version. The second gap is bigger, and it is focus.
Where should keyboard focus go after a client-side route change?
Focus belongs at the top of the new main content. A full browser navigation does this for free by resetting focus to the start of the document. A client-side navigation does not, because from the browser's point of view nothing navigated — React replaced some nodes. The link the user activated is one of those nodes, so focus falls to <body> and sequential navigation restarts at the top of the page, walking the entire header again on every click.
'use client';
import { useEffect, useRef } from 'react';
import { usePathname } from 'next/navigation';
export function RouteFocus({ children }: { children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const pathname = usePathname();
const first = useRef(true);
useEffect(() => {
if (first.current) { first.current = false; return; } // the browser already focused the document on load
ref.current?.focus({ preventScroll: true });
}, [pathname]);
return <div ref={ref} tabIndex={-1}>{children}</div>;
}
Three details in those fifteen lines earn their place. Skipping the first render matters because on initial load the browser has already put focus at the document start, and stealing it again is noise. preventScroll: true matters because Next.js restores scroll position across navigations and an unguarded .focus() call scrolls the container into view and fights that restoration. And tabIndex={-1} is what makes a plain div focusable by script while staying out of the Tab sequence; without it, .focus() silently does nothing.
I wrap the layout's main region rather than the page, so the focus target still exists while a loading.tsx boundary is rendering:
// app/layout.tsx
<a href="#main" className="skip-link">Skip to content</a>
<Nav />
<RouteFocus>
<main id="main">{children}</main>
</RouteFocus>
The skip link above it solves the other half of the same problem, and it has two requirements: it must be the first focusable element in the DOM, and it must become visible when focused. A skip link pointing at #main only works if the target can receive focus — which is the tabIndex={-1} rule again.
How do I trap focus in a modal without a focus-trap library?
Use the native <dialog> element and open it with showModal(). HTMLDialogElement.showModal() puts the dialog in the browser's top layer, renders the ::backdrop pseudo-element, makes everything outside it inert, and closes on Escape by firing a cancel event. That is most of the feature set focus-trap packages were written to reimplement in JavaScript.
The trap comes from the method call, not from the markup. A <dialog open> rendered declaratively is a non-modal dialog: no top layer, no backdrop, no focus trap. In React that means driving it from an effect rather than from JSX:
'use client';
import { useEffect, useRef } from 'react';
export function Modal({ open, onClose, children }: {
open: boolean; onClose: () => void; children: React.ReactNode;
}) {
const ref = useRef<HTMLDialogElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
if (open && !el.open) el.showModal(); // focus trap + top layer + ::backdrop
if (!open && el.open) el.close();
}, [open]);
return <dialog ref={ref} onCancel={onClose} onClose={onClose}>{children}</dialog>;
}
Two behaviours still need code from me. Initial focus goes to the first focusable descendant, which is usually a close button, so I put autoFocus on the element I actually want. And focus is not restored on close: I capture document.activeElement before calling showModal() and call .focus() on it after close().
| Approach | Traps focus | Escape closes it | Top layer |
|---|---|---|---|
<dialog> opened with showModal()
|
Yes, in the browser | Yes, fires cancel
|
Yes, with ::backdrop
|
<dialog open> written in JSX |
No | No | No |
popover attribute |
No, by design | Yes, light dismiss | Yes |
<div role="dialog"> |
Only what you write | Only what you write | No, z-index only |
The popover attribute is the right choice for menus, tooltips and non-blocking panels precisely because it does not trap focus. Reaching for it to build a confirmation dialog is the mistake I now see most often, because both features shipped close enough together to feel interchangeable.
When should I use inert instead of aria-hidden?
Use inert when the user must not reach the content at all, and aria-hidden only when the content is visible but redundant for assistive technology. They are not interchangeable. aria-hidden="true" hides a subtree from the accessibility tree while leaving every button inside it tabbable, which produces the worst available state: focus lands on a control the screen reader will not describe.
inert is a single global HTML attribute that removes a subtree from the Tab order, the accessibility tree, and pointer hit testing at once. React 19 treats it as a real boolean prop, so inert={isDrawerOpen} compiles to the right thing; React 18 expected a string and warned on a boolean.
<div id="app-shell" inert={isDrawerOpen}>
{/* every control in here is untabbable and invisible to screen readers */}
</div>
<aside role="dialog" aria-modal="true">{drawer}</aside>
-
inert— background content behind a custom drawer, off-screen carousel slides, a form section that is temporarily unavailable. -
aria-hidden— decorative icons sitting next to a visible text label, content duplicated purely for layout. -
display: none— removes the element from layout and both trees; the correct default when content is genuinely not present.
With showModal() none of the three is needed: the browser applies inertness to everything outside the top layer itself.
How do I announce async updates like search results or form errors?
Put the live region in the DOM before the content arrives, then change its text. A screen reader announces changes inside a region it was already observing, so mounting <div aria-live="polite">42 results</div> at the same moment the results arrive typically announces nothing at all.
{/* Always mounted. Only the text inside it changes. */}
<p aria-live="polite" className="sr-only">
{status === 'loading' ? 'Searching' : `${count} results`}
</p>
Pick the politeness level deliberately. role="status" maps to aria-live="polite" and waits for a pause in speech, which is correct for result counts, saved indicators and toasts. role="alert" maps to aria-live="assertive" and interrupts immediately, which is correct only for validation failures and errors the user must handle now.
For form errors I skip the live region when I can. Moving focus to the first invalid input announces the message through that input's own aria-describedby, and it puts the caret where the fix has to happen. A live region tells the user something is wrong; focus tells them where.
How do I keep keyboard accessibility from regressing in CI?
Automated rule engines catch the markup mistakes and none of the focus mistakes. @axe-core/playwright finds missing labels, contrast failures and invalid ARIA, but it cannot tell you that focus went to <body> after a route change, because that state is perfectly valid HTML. Focus needs explicit assertions:
test('route change moves focus to main', async ({ page }) => {
await page.goto('/');
await page.getByRole('link', { name: 'Projects' }).click();
await expect(page.locator('main')).toBeFocused();
});
test('closing a modal returns focus to its trigger', async ({ page }) => {
const trigger = page.getByRole('button', { name: 'Delete' });
await trigger.click();
await page.keyboard.press('Escape');
await expect(trigger).toBeFocused();
});
Two tests, one per bug class, covering the two bugs keyboard users actually report. I run an axe scan over the main templates in the same suite for the markup half. Neither replaces the afternoon without a mouse — that is still the check that finds what no rule encodes.
FAQ
Q: Does Next.js move focus on a route change automatically?
A: No. The App Router injects a route announcer that reads the new document title into an aria-live region, but it does not change document.activeElement. Moving focus is application code.
Q: Should tabIndex={-1} go on the h1 or on a wrapper element?
A: A wrapper around main is the more robust target, because it survives pages that have no h1 or that stream one in late. Both are announced; the wrapper cannot go missing.
Q: Is inert safe to ship in 2026?
A: Yes. inert is supported in current Chrome, Edge, Safari and Firefox and has been for several release cycles. React 19 accepts it as a boolean prop, while React 18 required a string value.
Q: Do I still need a focus-trap library?
A: Not for modal dialogs, because <dialog> opened with showModal() traps focus in the browser. A library is still useful for non-dialog patterns such as an embedded wizard step that must contain focus without entering the top layer.
Q: Does aria-hidden stop an element from being focusable?
A: No, and that combination is a bug. aria-hidden="true" removes an element from the accessibility tree while leaving it in the Tab order, so a keyboard user can focus a control no screen reader will describe. Use inert instead.
None of this required a dependency. A fifteen-line client component, one HTML attribute, one native element and two Playwright assertions covered every keyboard bug I could find in an afternoon of not touching my mouse. The App Router gives you the announcement for free. The focus is still yours to move.
Originally published on devya.dev. Also on eng-ahmed.com. Built by Devya Solutions.
Top comments (0)