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
h1ormainon every navigation -
cdkTrapFocus(orMatDialog) on every modal - Form fields with linked label,
aria-invalidandaria-describedby - Errors announced via
role="alert" - Dynamic content pushed into an
aria-liveregion - 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.