Stack · Angular · accessibility engineering
Angular accessibility — the practical guide
Angular is the enterprise stack where accessibility matters most and is often tested least. Fortunately, Angular ships a serious a11y toolkit via@angular/cdk/a11y (FocusTrap, LiveAnnouncer, FocusMonitor, FocusKeyManager, InteractivityChecker, AriaDescriber). Angular Material builds on these primitives and is broadly accessible out of the box. This guide covers the Angular-specific patterns for WCAG 2.1 AA — written for teams shipping to markets under EAA 2025, ADA, and Israeli תקנה 35.
CDK a11y — the primitives
@angular/cdk/a11yis the Angular team's official a11y toolkit. It's framework-agnostic in spirit (works with any Angular app, not just Material). The four you'll use most:
- FocusTrap — restricts keyboard focus to a container. For modals, drawers, menus.
- LiveAnnouncer — announces a string to screen readers via an
aria-liveregion. For async state. - FocusMonitor — detects the origin of focus (mouse / keyboard / touch / program) and exposes it as a CSS modifier. For accurate focus-visible styling.
- FocusKeyManager / ListKeyManager — implements arrow-key navigation within a list or menu, per APG patterns.
If you're rolling a custom interactive pattern (tab list, combobox, tree, custom menu), start with the relevant KeyManager — the keyboard-interaction matrix from the ARIA Authoring Practices Guide is baked in.
Reactive forms with explicit labels
Angular's reactive forms are accessibility-neutral — you still need to wire label / for / aria-describedby manually. The most common mistake is a floating placeholder with nolabel:
// field.component.ts
@Component({
selector: 'app-field',
template: `
<label [htmlFor]="id">{{ label }}</label>
<input
[id]="id"
[formControl]="control"
[attr.aria-invalid]="control.invalid && control.touched"
[attr.aria-describedby]="error ? errorId : null"
/>
<p *ngIf="error" [id]="errorId" role="alert">{{ error }}</p>
`,
})
export class FieldComponent {
@Input() label!: string
@Input() control!: FormControl
@Input() error?: string | null
private static counter = 0
readonly id = `fld-${++FieldComponent.counter}`
readonly errorId = `${this.id}-err`
}Use a per-component counter or the upcoming @Input() signal-based ID helper. Don't rely on random numbers — SSR will produce hydration mismatches.
Focus management on route change
Angular Router does not move focus. WCAG 2.4.3 (Focus Order) and screen- reader UX both demand that focus move to meaningful content on navigation. Wire it in AppComponent:
// app.component.ts
import { Component, inject } from '@angular/core'
import { NavigationEnd, Router } from '@angular/router'
import { LiveAnnouncer } from '@angular/cdk/a11y'
import { filter } from 'rxjs'
@Component({ /* ... */ })
export class AppComponent {
private router = inject(Router)
private live = inject(LiveAnnouncer)
constructor() {
this.router.events
.pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
.subscribe(() => {
// Defer so the new route has rendered
requestAnimationFrame(() => {
const h1 = document.querySelector<HTMLElement>('h1, [role="main"]')
if (h1) {
h1.setAttribute('tabindex', '-1')
h1.focus({ preventScroll: false })
}
const title = document.title
this.live.announce(`Navigated to ${title}`, 'polite')
})
})
}
}Also update the document title on navigation via the title field on the route config (Angular 14+) — this is what screen readers read on navigation in many combinations.
Dialogs — FocusTrap and Material Dialog
Prefer @angular/material/dialog or the native<dialog> element. If you must hand-roll, wrap with cdkTrapFocus:
<div
*ngIf="open"
cdkTrapFocus
cdkTrapFocusAutoCapture
role="dialog"
aria-labelledby="dialog-title"
aria-modal="true"
>
<h2 id="dialog-title">{{ title }}</h2>
<ng-content></ng-content>
<button (click)="close.emit()">Close</button>
</div>cdkTrapFocusAutoCapture moves initial focus into the dialog when it opens and restores focus to the opener on close. Not doing either is the single most common custom-dialog accessibility bug.
LiveAnnouncer for async state
Async actions that complete visibly — “Saved” toasts, search-result counts, form-submit success — should announce via LiveAnnouncer:
import { LiveAnnouncer } from '@angular/cdk/a11y'
export class CartService {
private live = inject(LiveAnnouncer)
async addToCart(productId: string) {
await this.api.addToCart(productId)
this.live.announce(`Added to cart`, 'polite')
}
}For urgent messages (errors, critical warnings) pass 'assertive'. Use sparingly — assertive interrupts the user's current listening context.
Angular Material — known accessible patterns
Angular Material is one of the more accessibility-conscious component libraries. Defaults are broadly correct, but a few gotchas:
- mat-form-field — requires
<mat-label>. Floating placeholder alone does not substitute for a label. - mat-icon-button without text — set
aria-labelor screen readers announce “button” with no purpose. - mat-menu — keyboard and focus are handled; verify that trigger buttons have accessible names.
- mat-tab-group — uses role="tablist" correctly; ensure tab panels have descriptive
aria-label. - mat-snack-bar — announces via LiveAnnouncer automatically; don't double-announce.
- mat-autocomplete — correct ARIA combobox pattern; do not wrap in custom containers that break the relationship.
For non-Material apps, PrimeNG and NGX-Bootstrap have varying accessibility quality; audit each component before shipping.
Testing with cypress-axe + Playwright
Component / page-level with Cypress and cypress-axe:
// cypress/e2e/home.cy.ts
describe('Home page accessibility', () => {
it('has no violations', () => {
cy.visit('/')
cy.injectAxe()
cy.checkA11y(null, {
rules: {
'color-contrast': { enabled: true },
},
})
})
})Playwright + @axe-core/playwrightcovers anything Cypress can't and matches the engine the axle GitHub Action uses:
// e2e/home.spec.ts
import { test, expect } from '@playwright/test'
import AxeBuilder from '@axe-core/playwright'
test('home page', async ({ page }) => {
await page.goto('/')
const { violations } = await new AxeBuilder({ page }).analyze()
expect(violations).toEqual([])
})CI pipeline
name: Accessibility
on: [pull_request]
jobs:
a11y:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: asafamos/axle-action@v1
with:
url: ${{ secrets.PREVIEW_URL }}
fail-on: seriousFor enterprise Angular shops on Azure DevOps Pipelines or Bitbucket Pipelines, use axle-cli directly:
npx axle-cli scan https://staging.example.com \ --fail-on serious \ --json-out axle-report.json \ --markdown-out axle-report.md