June 18, 2026 · Tommy Bordas

Making an Angular application accessible, the patterns that matter

angularaccessibilitya11ycdkspa

An Angular application raises accessibility challenges that classic sites never face: navigation without a reload, content that changes silently, focus lost on every transition. Here are the concrete patterns to make an Angular SPA accessible, with the CDK and real code.

The SPA trap: silent navigation

On a classic site, changing pages reloads the document: the screen reader announces the new page, focus restarts from the top. In a Single Page Application, none of that happens. Angular swaps part of the DOM, the URL changes, but for a screen-reader user, nothing happened. Focus stays stuck on the clicked link, and no announcement signals the arrival on a new view.

This is Angular's number-one accessibility problem, and it stays invisible until you test with the keyboard or a screen reader.

Takeaway: in a SPA, you have to manually recreate what the browser did for free: announce the page change and move focus. Without it, a screen-reader user is lost from the very first navigation.

Pattern 1: announce route changes

Angular's CDK provides LiveAnnouncer, which pushes a message into an aria-live region without moving focus. Use it to announce every view change.

import { Component, inject } from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { Title } from '@angular/platform-browser';
import { filter } from 'rxjs';

@Component({ selector: 'app-root', /* ... */ })
export class AppComponent {
  private router = inject(Router);
  private announcer = inject(LiveAnnouncer);
  private title = inject(Title);

  constructor() {
    this.router.events.pipe(
      filter(e => e instanceof NavigationEnd)
    ).subscribe(() => {
      this.announcer.announce(`Page: ${this.title.getTitle()}`, 'polite');
    });
  }
}

Pattern 2: move focus to the right place

Announcing isn't enough: you also have to move focus to the new view, otherwise Tab resumes where it was. The right target is usually the <h1> or the main container of the new page.

this.router.events.pipe(
  filter(e => e instanceof NavigationEnd)
).subscribe(() => {
  const main = document.querySelector('main h1') as HTMLElement | null;
  // tabindex=-1 makes the element focusable without adding it to the tab order
  main?.setAttribute('tabindex', '-1');
  main?.focus();
});

Pattern 3: trap focus inside a modal

When a dialog opens, focus must stay inside it while it's open, and return to its starting point on close. The CDK provides cdkTrapFocus for this.

<div class="modal" cdkTrapFocus cdkTrapFocusAutoCapture role="dialog"
     aria-modal="true" aria-labelledby="modal-title">
  <h2 id="modal-title">Confirm deletion</h2>
  <button (click)="confirm()">Confirm</button>
  <button (click)="close()">Cancel</button>
</div>

If you use @angular/material, MatDialog already handles focus trapping, the dialog role and focus return. One more reason to lean on proven components rather than reinventing a modal.

Pattern 4: accessible reactive forms

An Angular form must tie its errors to the relevant field. The aria-invalid and aria-describedby attributes make that link for the screen reader.

<label for="email">Email address</label>
<input id="email" type="email" formControlName="email"
       [attr.aria-invalid]="email.invalid && email.touched"
       [attr.aria-describedby]="email.invalid ? 'email-error' : null">

@if (email.invalid && email.touched) {
  <p id="email-error" role="alert">Please enter a valid email.</p>
}

The role="alert" makes the error announce as soon as it appears, without moving focus.

Pattern 5: track keyboard vs mouse focus

The CDK exposes FocusMonitor, which tells a keyboard focus apart from a mouse focus. Handy to show the focus ring only on keyboard, without the brittle homemade code we used before :focus-visible.

CDK tool Role
LiveAnnouncer Announce a message in aria-live
cdkTrapFocus Confine focus within an area (modal)
FocusMonitor Know if focus came from keyboard or mouse
cdkAriaLive Declarative live region in the template

What about signals and zoneless?

Good news: signals and zoneless mode change nothing for accessibility. The render is still standard DOM. The one thing to watch is the same as before: when content updates dynamically (a search result, a counter, a message), you have to announce it through an aria-live region or LiveAnnouncer, otherwise the change goes unnoticed by a screen reader. On this topic, see also: Angular signals and zoneless, what really changes.

The Angular accessibility checklist

  • Route change announced via LiveAnnouncer
  • Focus moved to the h1 or main on every navigation
  • cdkTrapFocus (or MatDialog) on every modal
  • Form fields with linked label, aria-invalid and aria-describedby
  • Errors announced via role="alert"
  • Dynamic content pushed into an aria-live region
  • Full keyboard and screen-reader testing

Going further

On optimizing an Angular application, see also: How I optimized an Angular application performance by 40%.

Want to bring your Angular application into accessibility compliance? Let's talk.