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-live region. 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-label or 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: serious

For 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