Summary
The SPA swap runtime updates the URL, the document title and the scroll position, and then stops. Focus is left wherever the swapped-away DOM put it — in practice on a detached node, so the browser resets it to <body> — and nothing announces that the page changed. A keyboard user must tab from the top of the document after every navigation; a screen-reader user gets no indication a navigation happened at all beyond a title change they may not hear.
Impact (real app)
Every client-side navigation in analyticshq is affected: login → register, dashboard → /sites/new, and the whole marketing nav — 276 link occurrences across the resources/ tree. There are no aria-live regions and no skip link in the app (grep -rn "aria-live" resources/ and grep -rn "skip-link|Skip to" resources/ both return nothing), because there was nothing to reach for: the swap is owned by the router, so an app cannot fix this without reimplementing it. This is not a case of the app not using an available primitive — the primitive does not exist.
Evidence in stx
In packages/router/src/:
grep -ril "focus" packages/router/src/→ no files. Same forannounce,aria-live,live-region,liveRegion,tabindex,restoreFocus,autofocus.packages/router/src/client.ts:1229,1249,1255,1391— the onlyaddEventListenercalls:click,popstate,mouseover(prefetch),DOMContentLoaded. No focus handling anywhere in the 1396-line runtime.packages/router/src/client.ts:736and:1094— post-swap work isif(o.scrollToTop&&!hash)window.scrollTo({top:0,behavior:'instant'}).packages/router/src/client.ts:157(applyTitle) and:1099— the title is set; nothing else runs.packages/router/src/client.ts:45— the options object:{container, loadingClass, viewTransitions, cache, scrollToTop, prefetch, progress, progressColor, progressHeight, interceptAllLinks, prefetchCacheMax}. No focus or announce option to opt into.packages/router/src/index.ts:1-45— nothing route-a11y-shaped is exported.
Prior art
- Nuxt —
<NuxtRouteAnnouncer>is shipped and mounted by the default app template; it renders a visually-hidden polite live region that receives the new route's title on every navigation. - Angular —
RouterModulefocus options plus CDKLiveAnnouncer. - Remix / React Router —
<ScrollRestoration>alongside documented focus-management guidance (move focus to the route's heading or a wrapper withtabindex="-1"). - Ember —
ember-a11y-refocusis the community-standard addon and does exactly both halves.
Ask
- After a successful swap, move focus into the new content region — the swap container, or a
[data-stx-focus-target]if the page names one — by settingtabindex="-1"on it and calling.focus({ preventScroll: true }). Should be on by default, withfocusOnNavigate: falseto opt out. - Ship a route announcer: a single visually-hidden
<div aria-live="polite" aria-atomic="true">created once by the runtime and updated with the new document title on each navigation. On by default,announceRoute: falseto opt out. - Add a
focusTargetoption so an app can point at its own<h1>or main landmark. - Document a skip-link recipe that works with the swap (the link's target must survive or be re-established after each swap).