Build1 publisher3 min readPublished Updated
A client-side route change in Next.js drops keyboard focus back to <body>
The App Router's built-in announcer writes the new page name into an aria-live region on every client-side navigation. Focus stays where the unmounted link was, so the next Tab press starts again at the site header.
The Engineer · Build desk
What happened
- That same navigation leaves keyboard focus on the link React just unmounted, so document.activeElement falls back to body and the next Tab press restarts at the top of the page.
- The field notes close the gap with a fifteen-line client component that watches usePathname() and calls .focus({ preventScroll: true }) on a wrapper div carrying tabIndex={-1}.
- The author dates the urgency of the bug class to the European Accessibility Act, which has been applicable since 28 June 2025.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- constraint The framework covers the audio channel only, so keyboard users depend entirely on application code for where focus sits after a route change.
- cost Every App Router project carries the same fifteen lines and maintains them, because the announcer ships in the framework and focus is left to the app.
- decision Any team hiding a closed drawer or off-canvas menu now has to choose between aria-hidden and inert, and the choice decides whether those controls stay in the Tab order.
- capability A team that opens modals with showModal() gets the focus trap and the top layer from the browser and can stop maintaining a JavaScript focus trap.
A full browser navigation resets focus to the start of the document for free [2]. A client-side navigation in the App Router does not, because from the browser's point of view nothing navigated: React replaced some nodes, and one of them was the link the user activated [3]. `document.activeElement` falls back to `<body>`. Sequential navigation then restarts at the top of the page, walking the entire header again on every click [3].
The announcer is separate machinery. Next.js renders a visually hidden `aria-live` region on each client-side navigation and writes the new page name into it, reading `document.title` first and falling back to the page's `h1` [1]. Announcing is all it does [14]. Its only output is read aloud, so a sighted keyboard user running no screen reader gets nothing from the framework about where they now are in the document [1].
The fix in the field notes is a client component that watches `usePathname()` from `next/navigation` and calls `.focus({ preventScroll: true })` on a wrapper carrying `tabIndex={-1}` [4]. Three details matter. `tabIndex={-1}` is what makes a plain `div` focusable by script while keeping it out of the Tab sequence; without it, `.focus()` silently does nothing [5]. The effect skips the first render, because on initial load the browser has already put focus at the document start and stealing it again is noise [6]. `preventScroll: true` is there because Next.js restores scroll position across navigations, and an unguarded `.focus()` scrolls the container into view and fights that restoration [7].
The wrapper goes around the layout's main region, not the page, so the focus target still exists while a `loading.tsx` boundary is rendering [8]. The announcer has a second failure mode the wrapper does not touch: a title resolved late by `generateMetadata` in a streamed segment can land after the announcer fires, so what gets read is the previous page's name [9]. A distinct, statically resolved `title` per route is the reliable version [9].
The post's author wrote that he found the bug "not with an audit tool, but by unplugging my mouse for an afternoon" [10]. Focus is lost in the transition between two renders, so neither the DOM before the navigation nor the DOM after it holds the evidence [2]. He dates the stakes to the European Accessibility Act, applicable since 28 June 2025 [13]. The post does not name a WCAG success criterion; the regulation is cited by applicability date [4].
Two adjacent defaults in the same notes are worth checking in the same pass. `inert` removes a subtree from the Tab order, the accessibility tree and pointer hit testing in one declaration, while `aria-hidden="true"` removes it from the accessibility tree only and leaves every control inside it tabbable [11]. A closed drawer marked `aria-hidden` is therefore a set of buttons a keyboard user can tab into and a screen reader will not describe [3]. The native `<dialog>` element traps focus and renders in the browser top layer only when opened imperatively with `showModal()`; rendering `<dialog open>` in JSX produces a non-modal dialog with no focus trap and no backdrop [12].
What to watch
- A Next.js release that moves focus itself on client-side navigation would make the wrapper component redundant.
- Whether enforcement under the European Accessibility Act names a specific success criterion for client-side focus loss.
- Lint rules or codemods that replace aria-hidden with inert in drawer and modal patterns.