Focus does not follow a soft navigation
A soft navigation in the App Router feels like a page change to sighted mouse users and like nothing happened to anyone navigating by keyboard or screen reader. The pathname updates, the document title may update, and focus stays on the link you just activated — or on a node that no longer exists. That is not a polish gap. It is a broken orientation cue, and it ships quietly because automated scans rarely catch it.
What a full page load used to do for you
A traditional navigation tears down the document and builds a new one. The browser resets focus to the top of the page, the screen reader treats the load as a new context, and the title change is part of that announcement. You did not write that behavior. You inherited it.
Soft navigation — Next.js App Router transitions, client `<Link>` clicks, view-transition experiments — keeps the shell alive on purpose. That is the performance win. It is also why focus and announcement stop being free. The framework swapped the DOM; it did not restore the browser's accessibility contract.
We already refuse to become an SPA just to get prettier transitions. Soft navigations still exist inside a mostly server-rendered app. Every one of them needs the same explicit focus move a SPA would need, or keyboard users land in the wrong place after every click through the primary nav.
Move focus to the new view's heading
After the new segment renders, put keyboard focus on the page's primary heading. Give that `h1` `tabIndex={-1}` so it is programmatically focusable without inserting itself into the normal tab order, then call `.focus()` once the content is in the DOM. Screen reader users hear the heading. Keyboard users start tabbing from the top of the new view instead of from a ghost of the old link.
Do this after paint of the destination, not in the click handler. If you focus too early, you focus a heading that is about to unmount, or you race a streaming segment and land on a placeholder. A small `useEffect` keyed on the pathname (or a layout effect after the segment commits) is enough. Skip scrolling the window unless the product actually wants a jump — focus and scroll are separate decisions.
Visible focus styles matter here. If your global CSS strips outlines and never replaces them on `:focus-visible`, a correctly moved focus is still invisible. The reduced-motion bug we shipped taught the same lesson from another angle: the branch you do not exercise in normal review is the one that fails in production.
A live region is a complement, not a substitute
Keep a single polite live region mounted for the life of the app — visually hidden, `aria-live="polite"`, `aria-atomic="true"`. On route change, set its text to something short like the new page title. Clear it after a beat so the next navigation can announce again. That gives screen reader users an explicit "you are here" even when focus management is imperfect.
Do not put the whole main content inside a live region. Do not announce on every trivial search-param tweak. Do not use `assertive` for ordinary navigations — assertive interrupts, and a marketing site does not need to shout every time someone opens Work. Polite, short, and tied to real view changes.
Title updates belong in the same pass. If `document.title` still says the previous page, both the browser chrome and any live-region copy that reads from it will lie. Soft navigation without a title update is the same class of bug as soft navigation without a focus move.
Where this shows up in our builds
Marketing sites with a persistent header are the usual offenders: every primary-nav click is a soft navigation, and none of them move focus into the new hero or page title. Admin surfaces are worse when a sidebar link swaps a large client island — the URL changes, the table updates, and focus remains on the sidebar item forever.
Modals and drawers are a related, tighter version of the same problem. Prefer the native `<dialog>` with `showModal()` so focus trapping and restore come with the platform. When you build a custom overlay, return focus to the control that opened it on close. Leaving focus in a detached tree is how keyboard sessions die after "Cancel".
Feature-gate flashes taught us not to render a lie while data is pending. Focus management is the sibling rule for navigation: do not leave the user oriented to a view that is gone. Pending content can wait; false orientation should not ship.
How we check it before it ships
Automated axe runs will not save you here. Tab through the primary nav with a keyboard after every soft route. Confirm focus lands on the new `h1`, the focus ring is visible, and VoiceOver or NVDA announces the new context. Repeat with a modal open and closed. That is a five-minute pass and it catches what CI ignores.
Add the focus move and live region once at the app shell — layout or a small client provider — so individual pages do not reinvent it. Pages still own a real `h1`. The shell owns the move. If a route has no heading, that is a content bug; do not paper over it by focusing a random landmark.
The acceptance line is simple: after any in-app navigation, a keyboard user can tell what page they are on without looking at the address bar, and the next Tab key does not disappear into the old chrome. If that fails, the soft navigation is unfinished.
Questions
- Does Next.js App Router move focus on soft navigation for you?
- No. It may update the document title, but focus stays where it was. You still need to move focus to the new view's heading (or equivalent) after the destination renders, and optionally announce via a polite live region.
- Should I focus a live region instead of the page heading?
- Prefer focusing the new h1 with tabIndex={-1}. That orients both keyboard and screen reader users on real content. Use a polite live region as a complement for the title announcement, not as the only focus target.
- Is this the same problem as prefers-reduced-motion bugs?
- Same family: a path most reviewers never exercise, invisible to common automated checks, and treated as polish until someone depends on it. Reduced motion breaks animation branches; missing focus moves break orientation after every soft route change.
Sources
- WAI-ARIA — Live regions — Polite announcements for non-interruptive status updates.
- MDN — HTMLElement.focus() — Programmatic focus after view changes; pair with tabindex=-1 on headings.
- Next.js — Linking and navigation — Soft navigation behavior in the App Router.