0016: Navigation focus management
Status: Accepted Date: 2026-05-10
Context
ADR 0013 and ADR 0015 both explicitly deferred focus management as a follow-up:
ADR 0013: A real router needs to handle scroll position on back-nav, focus on route change, and announce navigation to screen readers. Each is its own follow-up.
ADR 0015: No focus management. ADR 0013 mentioned this; still its own follow-up. Patterns vary (focus
<main>, focus an announce region, integrate with<title>change for screen readers).
The accessibility gap is real and well-documented. In traditional multi-page apps, the browser fires a full document load and screen readers announce the new page. In SPAs the document doesn't change; the framework replaces content reactively but emits no event the AT can hear. Users on screen readers / keyboard-only / switch-access get stuck on the previous page's interactive widget, with no signal that the route changed.
The shipping ecosystem has converged on a small pattern: move
keyboard focus into the new page's main landmark (or to a labeled
region) after navigation. NVDA, JAWS, VoiceOver all announce the
focused element's role + accessible name when focus lands on a
new region. Routes that need a richer announce can layer an
ARIA live region on top, but for the common case "focus <main>"
is the right default.
The integration shape is identical to ADR 0015 (manageNavScroll):
subscribe to onNavigate(), do the focus work in a microtask so
route-driven DOM updates flush first, pair with preventScroll: true
so it doesn't fight scroll restoration.
Decision
Ship manageNavFocus(options?) in @purityjs/core. Subscribes
to onNavigate() and moves keyboard focus into the new page's
landmark element on every forward nav.
// entry.client.ts
import { hydrate, interceptLinks, manageNavFocus, manageNavScroll } from '@purityjs/core';
hydrate(document.getElementById('app')!, App);
interceptLinks();
manageNavScroll();
manageNavFocus();
Concretely:
manageNavFocus(options?: ManageNavFocusOptions): () => void— registers a focus handler. Returns a teardown. No-op on the server.- Default behavior mirrors
manageNavScroll's "hash target first" priority so focus and scroll target the same element:- URL has hash + element with that
idexists → focus that element. - URL has no hash, OR the hash target is missing → focus the first
element matching
selector(default'main').
- URL has hash + element with that
selectoroption — CSS selector for the fallback landmark. Defaults to'main'. Common overrides:'h1'(focus the heading),'[role="region"][aria-label]'(a labelled region), app-specific class.onNavigateoption — replace the default handler entirely. Receives(url, replace)from {@link onNavigate}; do whatever focus / live-region announce /<title>integration work fits your app. When supplied,selectoris ignored.- Tabindex handling. Landmark elements (
<main>,<section>, …) aren't focusable by default. The handler addstabindex="-1"before calling.focus()when no tabindex is set, making the element programmatically focusable without injecting it into the keyboard tab order. Existing tabindex values are preserved — users opting their own element into the tab order keep their choice. preventScroll: trueon the focus call somanageNavScroll's scroll-to-top isn't undone by focus auto-scrolling the page back to the landmark.- Microtask defer. Route handlers may mount the target landmark
synchronously in response to the reactive URL signal, but the DOM
flushes after the current task. Deferring gives the new landmark
time to exist before lookup. Identical reasoning to
manageNavScroll.
Explicit non-features
- No ARIA live region — apps that prefer announcing route
changes via an
<div aria-live="polite">instead of moving focus can pass{ onNavigate: (url) => liveRegion.textContent = url.pathname }. Defaulting to focus-move matches the shipping ecosystem and works for both screen-reader and keyboard-only users. - No
<title>synchronisation. Combininghead()for per-route<title>withmanageNavFocusproduces the right announce behavior (focus lands → AT reads the new accessible name; title becomes the new page in the tab bar). The framework doesn't prescribe how user code updates<title>. - No focus restoration on back-nav. Browsers handle scroll
restoration on back/forward natively; focus restoration is murkier
and rarely what users want (focusing a stale interactive widget
after back-nav is confusing). Apps that want it implement a
custom
onNavigatehandler that readsdocument.title/ history state. - No focus-trap inside route content. Different scope — modal/dialog focus trapping is its own concern.
Consequences
Positive:
- Closes the accessibility gap that ADR 0013 + 0015 both flagged.
SPAs that pair
interceptLinks() + manageNavScroll() + manageNavFocus()now announce route changes to screen readers in the same way multi-page apps do. - Hash-target priority matches
manageNavScrollso the two primitives operate on the same element. No fight; coherent UX. preventScroll: truekeeps focus from undoing scroll restoration.- Custom
onNavigatehandler is the same shape asmanageNavScroll's — apps with bespoke focus / announce flows use one consistent extension point.
Negative:
- One more opt-in primitive (
manageNavFocusjoinsinterceptLinks+manageNavScroll+onNavigate). The router module's surface is growing; future ADRs should think hard about whether yet anothermanageNav*is the right shape or if a singleconfigureNavigation({ scroll, focus, … })would consolidate better. Phase 1 prefers individual primitives so each is tree-shakable. - Adding
tabindex="-1"mutates the user's DOM. The attribute is visible in dev tools but harmless; documented in the docstring. Custom selectors that target focusable elements (<h1>withtabindex="0") bypass the mutation entirely. - Default selector
'main'doesn't handle pages with multiple<main>elements (rare per the HTML spec — one landmark per page) or pages with no<main>at all (no-op). Custom selector covers both cases.
Neutral:
- Two exports added (
manageNavFocus,ManageNavFocusOptions). Tree-shaken when unused. - Microtask deferral makes focus timing observable as asynchronous
in tests; users who want sync behavior call
el.focus()directly from their customonNavigate.
Alternatives considered
Move focus inside navigate() itself. Always focus, no opt-in.
Rejected: framework would make a11y decisions for apps that have
their own custom focus management (modal-heavy apps, kiosks with
hardware focus). Opt-in primitive matches the rest of the router's
contract.
ARIA live region instead of focus. A <div aria-live="polite">
that the framework writes route names into is one common pattern.
Rejected as the default because it doesn't help keyboard-only users
who aren't running an AT, and the right text to announce varies per
app (URL pathname? document.title? a custom announce string?).
Apps wanting this layer it via onNavigate.
Focus <h1> by default. Some routers default to the page
heading. Rejected: <h1> content may be reactive-text or absent on
some routes. <main> is structurally guaranteed on
properly-marked-up pages and works as the screen-reader landmark
without depending on text content.
Auto-set tabindex="-1" permanently. Some routers leave the
tabindex in place after the focus call. Rejected because that's the
current behavior — once added, it stays. Removing it on blur was
considered but adds DOM listeners + complexity for no observed
benefit (tabindex="-1" is harmless at rest).
Bake focus into the SSR side via <main autofocus> markup
generation. autofocus only fires on initial page load, not on
SPA navigation. Wrong shape for this problem.
Fold focus + scroll into a single manageNavigation({ focus, scroll }) primitive. Consolidation is cleaner from one angle
(single import, single teardown) but uglier from the
tree-shaking angle (the unused half ships with the used half). The
current per-concern primitives match interceptLinks and let
apps pick exactly what they need.