Managing Route-Change Focus in Angular and Vue

Permalink to "Managing Route-Change Focus in Angular and Vue"

A client-side route change swaps the view without a document load, so the browser does none of the focus housekeeping a real navigation performs — focus is left on the now-unmounted link or reset to document.body, and screen reader users get no signal that the page changed. This page shows how to move focus to the new view and announce it in both Angular Router and Vue Router, preventing the silent-navigation failure that breaks WCAG 2.4.3 Focus Order (Level A) in single-page apps.

This is a focused companion to the broader focus management in single-page apps reference, which covers focus after modals and inside data grids as well as routing.


Spec reference

Permalink to "Spec reference"

There is no single ARIA attribute for “route changed.” The obligation comes from WCAG success criteria and is met with standard focus APIs:

Concern Mechanism Normative source
Focus lands in a logical place after navigation HTMLElement.focus() on a tabindex="-1" target WCAG 2.4.3 Focus Order (A)
The navigation is announced without moving focus to the message aria-live="polite" status region WCAG 4.1.3 Status Messages (AA)
The routed region is identifiable A landmark (<main>) or labelled heading WCAG 1.3.1 Info and Relationships (A)
Focus indicator visible on the target :focus styling on the focused heading WCAG 2.4.7 Focus Visible (AA)

tabindex="-1" makes an element programmatically focusable (via .focus()) without adding it to the Tab order. It is the correct value for a heading you focus after navigation: reachable by script, invisible to sequential Tab navigation. Never use a positive tabindex.


When to use vs. NOT to use

Permalink to "When to use vs. NOT to use"

Move focus to the heading when

Permalink to "Move focus to the heading when"
  • The route change loads a genuinely new view (a new page of the app), which is the common case.
  • The routed content has a clear <h1> or a labelled top-of-content target to receive focus.

Do NOT force a focus move when

Permalink to "Do NOT force a focus move when"
  • The change is an in-place update to the current view (a tab within a page, a filter applied to a grid), not a navigation. Those use their own patterns — see composite widget roles and states for tab panels, and announcing async loading states in data tables for filtered results.
  • Focus is already inside a control the user is operating; yanking it to a heading mid-interaction is more disruptive than the silent-navigation problem you are solving.

The misuse to avoid: calling window.scrollTo(0, 0) and considering the job done. Scrolling is visual only; it moves neither keyboard focus nor the screen reader cursor, so AT users are left stranded on the old, unmounted view.


Annotated code — Angular Router

Permalink to "Annotated code — Angular Router"

Angular’s Router emits a NavigationEnd event when navigation completes. Subscribe once in the root component, then focus a skip target and announce the title.

<!-- WCAG 1.3.1: main landmark identifies the routed region -->
<!-- The tabindex=-1 heading is the focus target after each navigation -->
<a class="skip-link" href="#view-heading">Skip to content</a>
<main>
  <h1 id="view-heading" tabindex="-1">{{ pageTitle }}</h1>
  <router-outlet></router-outlet>
</main>

<!-- WCAG 4.1.3: polite region announces the navigation without stealing focus -->
<div id="route-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>
// app.component.ts
// WCAG 2.4.3 Focus Order: move focus into the new view on navigation end
import { Component, OnInit } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';

@Component({ selector: 'app-root', templateUrl: './app.component.html' })
export class AppComponent implements OnInit {
  constructor(private router: Router) {}

  ngOnInit(): void {
    this.router.events
      .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
      .subscribe(() => {
        const heading = document.getElementById('view-heading');
        if (heading) {
          heading.focus();               // SC 2.4.3 — focus enters the new view
        }
        const status = document.getElementById('route-status');
        if (status && heading) {
          status.textContent = '';
          // rAF ensures the clear flushes before the new text (reliable re-announce)
          requestAnimationFrame(() => {
            // SC 4.1.3 — announce the navigation politely
            status.textContent = `Navigated to ${heading.textContent?.trim()}`;
          });
        }
      });
  }
}

Set pageTitle from each route’s data (for example data: { title: 'Invoices' }) so the focused heading and the announcement carry the correct name. Because the NavigationEnd fires after the outlet renders, the heading exists in the DOM by the time you call .focus().


Annotated code — Vue Router

Permalink to "Annotated code — Vue Router"

Vue Router exposes router.afterEach, which runs after each confirmed navigation. Use nextTick so the new view has rendered before you focus its heading.

// router/focus.js
// WCAG 2.4.3 Focus Order: focus the new view; WCAG 4.1.3: announce it politely
import { nextTick } from 'vue';

export function installRouteFocus(router) {
  router.afterEach((to) => {
    // Wait for the routed component to render into <router-view>
    nextTick(() => {
      const heading = document.getElementById('view-heading');
      if (heading) {
        heading.focus();                 // SC 2.4.3 — focus enters the new view
      }
      const status = document.getElementById('route-status');
      const label = to.meta?.title ?? heading?.textContent?.trim() ?? 'page';
      if (status) {
        status.textContent = '';
        requestAnimationFrame(() => {
          status.textContent = `Navigated to ${label}`; // SC 4.1.3
        });
      }
    });
  });
}
<!-- App.vue template -->
<!-- WCAG 1.3.1: main landmark; tabindex=-1 heading receives focus each navigation -->
<a class="skip-link" href="#view-heading">Skip to content</a>
<main>
  <h1 id="view-heading" tabindex="-1">{{ route.meta.title }}</h1>
  <router-view></router-view>
</main>
<div id="route-status" role="status" aria-live="polite" aria-atomic="true" class="sr-only"></div>

Define meta: { title: 'Invoices' } on each route record so to.meta.title supplies a stable announcement label even before the heading paints. The sr-only class visually hides the status region while keeping it in the accessibility tree — reuse the same utility described in aria-live regions for dynamic data.


Keyboard & AT behaviour

Permalink to "Keyboard & AT behaviour"
Event With this pattern Without it (failure)
Activate an in-app link Focus moves to the new view’s <h1>; AT reads the heading Focus stays on the unmounted link; next Tab starts at document.body
Screen reader after navigation Hears “Navigated to Invoices” then the heading Silence — no cue that the page changed
Press Tab after navigation Next focus is the first control inside the new view Tab restarts from the top of the document, skipping nothing meaningfully
Browser Back button NavigationEnd / afterEach fires again; focus re-enters view Focus lost; user cannot tell they navigated back

Integration context

Permalink to "Integration context"

Route-change focus is one slice of the parent focus management in single-page apps cluster, which also covers restoring focus after modals and moving focus inside virtualized grids. The announcement half of this pattern is an application of aria-live regions for dynamic data; if you are unsure whether to use polite or assertive for the “navigated to” message, it is always polite — see choosing between polite and assertive aria-live regions.


Gotchas

Permalink to "Gotchas"
  1. Focusing before the view renders. In both frameworks the routed component may not be in the DOM the instant navigation resolves. Angular’s NavigationEnd is generally safe, but in Vue you must wait for nextTick; otherwise getElementById('view-heading') returns null and focus silently stays put.
  2. A visible outline on the focused heading. A tabindex="-1" heading receives a focus ring by default. Do not remove it with outline: none — that fails SC 2.4.7. Style #view-heading:focus with a clear, contrast-compliant indicator, or :focus-visible if you want to suppress it only for pointer users.
  3. Announcing a stale title. If you read the heading’s textContent before the new view paints, you announce the previous page’s title. Prefer the route’s meta/data title, which is known at navigation time, and fall back to the heading text only once it has rendered.

FAQ

Permalink to "FAQ"
Where should focus go after a client-side route change?

Move focus to the new view’s main heading, given tabindex="-1" so it is programmatically focusable, or to a dedicated skip target at the top of the routed content. This tells screen reader and keyboard users that a new page loaded and places them at its start, which is the behaviour a full page navigation would have produced. Do not leave focus on the activated link or let it fall back to document.body.

Do I need a live region if I already move focus to the heading?

It is strongly recommended. Moving focus to the heading makes most screen readers read that heading, but timing and support vary, and a focused heading does not always convey that a navigation occurred. A short polite live region announcing the new page title gives a reliable, consistent “navigated to” cue that complements the focus move rather than replacing it.

Why does keyboard focus jump to the top of the page after navigation in my SPA?

Because the element that had focus, usually the link the user activated, is unmounted when the router swaps views, and the browser resets focus to document.body. The next Tab then starts from the very top of the document. Fix it by explicitly moving focus into the new view on the router’s navigation-complete event, before the user presses another key.


Permalink to "Related"

Back to Focus Management in Single-Page Apps