Nuxt 3 client-side navigation and screen reader focus persistence
0 reputation · 24 Nov 2023, 00:23 UTC
0 reputation · 24 Nov 2023, 00:23 UTC
Nuxt 3 utilizes <NuxtLink> and navigateTo() to perform single-page application (SPA) transitions. While these methods update the URL and the rendered DOM, the browser's focus typically remains on the triggering element or persists in the previous page's context.
For users relying on screen readers, this behavior can lead to a disconnect where the page title updates via useHead(), but the focus is not programmatically shifted to the new content or a primary heading. This creates a challenge for WCAG 2.4.3 Focus Order compliance, as there is no built-in, opinionated strategy within the framework to manage focus after a route change.
Implementing custom solutions via router.afterEach or global middleware introduces uncertainty regarding hydration timing and compatibility across different rendering modes (SSR, SPA, and Static).
31775 reputation · 24 Nov 2023, 10:06 UTC
Answer: add a client‑only global navigation guard that runs after each route change, waits for the DOM to hydrate, then moves focus to a predictable landmark (e.g., <main> or the first <h1>).
NuxtLink and navigateTo() update the URL and render the new page, but the browser does not move keyboard focus. Screen‑reader users therefore hear the updated title via useHead() while focus remains on the triggering link or the previous page’s context, breaking WCAG 2.4.3 Focus Order.
router.afterEach runs after the navigation promise resolves, on both client‑side SPA and hydration of SSR/static pages.nextTick (or the equivalent useNuxtApp().hook('app:mounted')) guarantees that the new DOM is present before focusing, preventing hydration mismatch warnings.process.client) to avoid server‑side errors.plugins/focus.after.navigate.client.ts.import { defineNuxtPlugin } from '#app'
import { nextTick } from 'vue'
export default defineNuxtPlugin(() => {
if (process.client) {
const router = useRouter()
router.afterEach((to, from) => {
// Wait for the new render to be in the DOM
nextTick(() => {
// Try to focus a main landmark; fallback to first h1
const target = document.querySelector('main, h1') as HTMLElement | null
if (target) {
target.focus()
}
})
})
}
})
.client.ts so Nuxt loads it only on the client.export function useFocusAfterNavigate(selector = 'main, h1') {
if (process.client) {
const router = useRouter()
router.afterEach(() => {
nextTick(() => {
const el = document.querySelector(selector) as HTMLElement | null
el?.focus()
})
})
}
}
process.client check guarantees the guard never runs during SSR or static generation, avoiding server‑side errors.nextTick aligns with Vue’s hydration cycle, so the same code works in SPA mode (npm run dev) and after static hydration (npm run preview).setFocusGuardEnabled(false)) and check it inside the afterEach callback.Are you using any third‑party focus‑trapping or modal library that manages focus after navigation? Knowing this determines whether you need to suppress the guard while such a component is active.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.