Angular Accessibility: The Complete WCAG 2.2 Guide
Angular is accessible when you build it that way — and it ships a dedicated toolkit to help. This guide covers the patterns that actually trip Angular apps up: semantic templates, ARIA [attr.aria-*] binding, focus on router navigation, dialogs with cdkTrapFocus, live announcements, and accessible reactive forms — with copy-ready code and a testing workflow that keeps them accessible.
Why Angular Accessibility Is Different
Angular does not make a page inaccessible on its own — it renders whatever elements you put in the template. Reach for a real <button>, <a>, <nav>, and <label> and you inherit the keyboard behavior, focus handling, and screen reader semantics those elements already provide. The trouble is that Angular makes it just as easy to bind a (click) handler to a <div> that looks like a button but is invisible to assistive technology.
Three things about the Angular model create accessibility work you would not have on a static site. First, client-side routing swaps the routed component without a full page load, so focus and screen reader context are never reset unless you do it. Second, change detection updates the DOM constantly — results, toasts, validation — and none of it is announced unless you use a live region. Third, component encapsulation hides markup behind reusable components, so one wrong choice (a div for a button, a missing label) repeats everywhere it is used.
Angular also has one syntax quirk that matters more for accessibility than any other: because ARIA attributes are not DOM properties, you bind them with attribute binding ([attr.aria-*]), not property binding. Get that, the routing focus, and the live regions right and most of Angular accessibility falls into place. If you also work in React, the same principles map cleanly onto our React accessibility guide.
The WCAG 2.2 Criteria Angular Apps Break Most
| Criterion | Level | What it requires in Angular |
|---|---|---|
| 1.3.1 Info & Relationships | A | Use semantic templates; associate labels and errors in reactive forms. |
| 2.1.1 Keyboard | A | Interactive elements must be real buttons/links, not (click) divs. |
| 2.1.2 No Keyboard Trap | A | Dialogs trap focus deliberately with cdkTrapFocus and release it on close. |
| 2.4.3 Focus Order | A | Move focus on NavigationEnd and on open/close of overlays. |
| 2.4.7 Focus Visible | AA | Keep a visible focus outline; FocusMonitor can style keyboard focus distinctly. |
| 3.3.1 Error Identification | A | Tie validation messages to fields with [attr.aria-describedby]. |
| 4.1.2 Name, Role, Value | A | Custom components expose an accessible name, role, and state. |
| 4.1.3 Status Messages | AA | Announce async updates through LiveAnnouncer or an aria-live region. |
For the full list, see the WCAG 2.2 Level AA requirements and the interactive WCAG 2.2 checklist.
1. Write Semantic Templates First
The single highest-impact rule in Angular accessibility: render the element that already does the job. A <button> is focusable, fires on Enter and Space, and announces its role. A <div (click)> does none of that until you add a role, tabindex, and keyboard handlers by hand — and get all three exactly right.
<!-- Inaccessible: not focusable, no keyboard, no role -->
<div class="btn" (click)="save()">Save</div>
<!-- Accessible: keyboard + role + focus for free -->
<button type="button" (click)="save()">Save</button>
<!-- Navigation is a list of links inside <nav> -->
<nav aria-label="Primary">
<ul>
<li><a routerLink="/pricing">Pricing</a></li>
<li><a routerLink="/guides">Guides</a></li>
</ul>
</nav>Use one <h1> per view and keep headings in order (h1 → h2 → h3) so screen reader users can navigate by heading. Wrap the routed content in <main>, and reach for <button> for actions and <a routerLink> for navigation — the difference matters to assistive tech even when they look identical. The @angular-eslint/template/click-events-have-key-events rule catches most of these as you type.
2. Bind ARIA with [attr.aria-*], Not Property Binding
This is the Angular-specific gotcha that catches every team. ARIA attributes have no matching DOM property, so Angular's [aria-label] property-binding syntax cannot reach them — it throws a template error. Bind ARIA through attribute binding instead. As a bonus, when the expression is null Angular removes the attribute entirely, which is exactly what you want for conditional states.
<!-- Wrong: aria-label is not a DOM property -> template error -->
<button [aria-label]="label">…</button>
<!-- Right: attribute binding -->
<button [attr.aria-label]="label">…</button>
<!-- Conditional: 'true' when invalid, attribute removed otherwise -->
<input [attr.aria-invalid]="invalid ? 'true' : null" />
<!-- Dynamic role and state on a custom element -->
<div [attr.role]="role" [attr.aria-expanded]="open">…</div>Static ARIA values that never change can stay as plain attributes (aria-label="Close"); only reach for [attr.aria-*] when the value is dynamic. Returning null rather than 'false'for the “off” case avoids shipping aria-invalid="false" or aria-hidden="false" across your app, which can confuse assistive tech. Every role and state is documented in our ARIA roles & attributes reference.
3. Manage Focus on Router Navigation
When the Angular Router changes routes, the browser does not reset focus the way a full page load would. Keyboard and screen reader users are left on whatever link they activated, now pointing at content that no longer exists. Subscribe to the Router's NavigationEnd event and move focus to the top of the new view.
import { Component, ElementRef, ViewChild, inject } from "@angular/core";
import { Router, NavigationEnd, RouterOutlet, RouterLink } from "@angular/router";
import { filter } from "rxjs";
@Component({
selector: "app-root",
standalone: true,
imports: [RouterOutlet, RouterLink],
template: `
<a class="skip-link" href="#main">Skip to main content</a>
<nav aria-label="Primary"><!-- … --></nav>
<main id="main" #main tabindex="-1">
<router-outlet />
</main>
`,
})
export class AppComponent {
@ViewChild("main") main!: ElementRef<HTMLElement>;
private router = inject(Router);
constructor() {
this.router.events
.pipe(filter((e) => e instanceof NavigationEnd))
.subscribe(() => this.main.nativeElement.focus());
}
}tabindex="-1" lets <main> receive programmatic focus without adding it to the Tab order. Prefer focusing a container or the new <h1> over announcing the whole page. This satisfies 2.4.3 Focus Order and depends on a working skip link. For the full picture of programmatic focus, traps, and restoration, see the focus management guide.
4. Accessible Dialogs & Focus Trapping
A dialog is the classic Angular focus challenge. When it opens, focus must move into it; while open, focus must stay trapped inside; on close, focus must return to the control that opened it. The CDK's cdkTrapFocus directive handles the trap, and cdkTrapFocusAutoCapture moves focus in on init and restores it to the previously focused element when the trap is destroyed.
import { Component } from "@angular/core";
import { A11yModule } from "@angular/cdk/a11y";
@Component({
selector: "app-confirm-dialog",
standalone: true,
imports: [A11yModule],
template: `
@if (open) {
<div class="backdrop" (click)="close()"></div>
<div
class="dialog"
role="dialog"
aria-modal="true"
aria-labelledby="dialog-title"
cdkTrapFocus
[cdkTrapFocusAutoCapture]="true"
>
<h2 id="dialog-title">Delete this project?</h2>
<p>This action cannot be undone.</p>
<button type="button" (click)="close()">Cancel</button>
<button type="button" (click)="confirm()">Delete</button>
</div>
}
`,
})
export class ConfirmDialogComponent {
open = false;
close() { this.open = false; }
confirm() { /* … */ this.close(); }
}You still add role="dialog", aria-modal="true", an accessible name via aria-labelledby, and Escape-to-close yourself. For production dialogs, MatDialog (Angular Material) and the headless CDK Dialog do all of this for you — backdrop, focus trap, focus restoration, and aria-modal — so you rarely need to hand-roll one. See the accessible modal pattern for the full interaction spec.
5. Announce Dynamic Content with LiveAnnouncer
Change detection updates the DOM silently. When search results load, a toast appears, or a form saves, a sighted user sees it instantly — a screen reader user hears nothing unless the change happens inside an aria-live region. The CDK's LiveAnnouncer manages that region for you: inject it and call announce().
import { Component, inject } from "@angular/core";
import { LiveAnnouncer } from "@angular/cdk/a11y";
@Component({ /* … */ })
export class SearchComponent {
private announcer = inject(LiveAnnouncer);
onResults(results: Result[]) {
// Polite: announced when the user is idle, never interrupts
this.announcer.announce(results.length + " results found", "polite");
}
onSessionExpiring() {
// Assertive: interrupts for genuinely urgent messages only
this.announcer.announce("Your session expires in 1 minute", "assertive");
}
}Prefer "polite" for status updates and reserve "assertive" for urgent, interrupting messages. If you render your own live region instead, it must exist in the DOM before its text changes — declare an empty <p aria-live="polite"> and bind its text rather than mounting it on demand with @if. This satisfies 4.1.3 Status Messages.
The @angular/cdk/a11y toolkit
Angular ships accessibility primitives most frameworks make you build by hand. You can use these without Angular Material — the CDK is the layer Material itself is built on.
LiveAnnouncer— announce messages through a managed aria-live region.cdkTrapFocus— trap focus in a region and optionally restore it on destroy.FocusMonitor— detect keyboard vs mouse vs touch focus to style keyboard focus distinctly (2.4.7).cdkAriaLive— turn any element into a live region declaratively.HighContrastModeDetector— respond to Windows High Contrast / forced-colors mode.
6. Accessible Reactive Forms
Angular's reactive forms give you validation state for free, but they say nothing about accessibility. Associate every input with a <label>, then link the error message with [attr.aria-describedby] and mark the field with [attr.aria-invalid] — using null so the attributes disappear when the field is valid.
<form [formGroup]="form" (ngSubmit)="submit()">
<label for="email">Email</label>
<input
id="email"
type="email"
formControlName="email"
[attr.aria-invalid]="emailInvalid ? 'true' : null"
[attr.aria-describedby]="emailInvalid ? 'email-error' : null"
/>
@if (emailInvalid) {
<p id="email-error" class="error">Enter a valid email address.</p>
}
<button type="submit">Create account</button>
</form>get emailInvalid(): boolean {
const c = this.form.controls.email;
// Only surface the error after the user has interacted with the field
return c.invalid && (c.touched || c.dirty);
}Never rely on a placeholder as the label — it disappears on input and fails contrast. Only set aria-invalid and aria-describedby once the user has touched the field, so assistive tech is not told about an error before it is shown. For labels, grouping with <fieldset>, validation, and error summaries, see the accessible forms guide and 3.3.1 Error Identification.
7. Custom Components & Host Bindings
When you genuinely need a custom widget — a disclosure, a tab set, a combobox — build it on real semantics and describe its state with ARIA. Because the toggle below is a real <button>, keyboard support and focus come free; you only add aria-expanded and aria-controls. Follow the ARIA Authoring Practices patterns exactly and mirror the ARIA state in your component state.
import { Component, Input } from "@angular/core";
@Component({
selector: "app-disclosure",
standalone: true,
template: `
<button
type="button"
[attr.aria-expanded]="open"
[attr.aria-controls]="panelId"
(click)="open = !open"
>
{{ label }}
</button>
<div [id]="panelId" [hidden]="!open">
<ng-content />
</div>
`,
})
export class DisclosureComponent {
@Input() label = "";
@Input() panelId = "";
open = false;
}When the component's own host element must carry a role or state (for example a custom listbox option), set it with the host metadata or @HostBinding — host: { role: 'option', '[attr.aria-selected]': 'selected' }. For widgets that need arrow-key navigation (tabs, menus, listboxes), implement a roving tabindex — the CDK's FocusKeyManager does the key handling for you. Our ARIA reference lists the exact roles and states for each pattern, and 4.1.2 Name, Role, Value explains why each is required.
Keyboard Rules for Angular
- Interactive = real
<button>or<a>, never a(click)div. - Move focus on router navigation and on overlay open/close.
- Trap focus in dialogs with
cdkTrapFocus; restore it on close. - Keep a visible focus outline (2.4.7).
- Roving
tabindexviaFocusKeyManagerfor arrow-key widgets.
See the keyboard accessibility guide.
Screen Reader Rules
- Announce navigation and async updates via
LiveAnnounceror live regions. - Every control has an accessible name (label or
[attr.aria-label]). - Icon-only buttons need a name; decorative icons get
aria-hidden="true". - Custom widgets expose role, name, and current value.
- Images use meaningful
alt, oralt=""if decorative.
Test with real AT — the screen reader testing guide.
8. Testing & Tooling
Automated checks catch a meaningful share of issues and stop regressions — but they find roughly a third to a half of WCAG problems, so they supplement rather than replace manual testing. Layer three tools into your Angular workflow:
// 1. Lint templates — @angular-eslint accessibility rules
// Enable in your ESLint config for *.html templates:
// "@angular-eslint/template/click-events-have-key-events"
// "@angular-eslint/template/label-has-associated-control"
// "@angular-eslint/template/valid-aria"
// "@angular-eslint/template/elements-content"
// "@angular-eslint/template/alt-text"
// 2. Component tests: Angular Testing Library + jasmine-axe
import { render } from "@testing-library/angular";
import { axe, toHaveNoViolations } from "jasmine-axe";
it("TextField has no axe violations", async () => {
const { container } = await render(TextFieldComponent, {
inputs: { label: "Email", error: "Enter a valid email" },
});
expect(await axe(container)).toHaveNoViolations();
});
// 3. End-to-end: axe-core in Playwright against real routes
import AxeBuilder from "@axe-core/playwright";
test("home page is accessible", async ({ page }) => {
await page.goto("/");
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
});The Angular Testing Library nudges you toward accessible queries — getByRole and getByLabelText only pass when the accessibility tree is correct, so writing tests this way surfaces missing names early. Finish every feature with a manual keyboard pass and a screen reader pass. Read our comparison of automated vs manual testing to see where each fits.
Common Angular Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
<div (click)="..."> used as a button. | Not focusable, not keyboard-operable, no role announced (WCAG 2.1.1, 4.1.2). | Use a real <button>. @angular-eslint's click-events-have-key-events flags this. |
[aria-label]="label" property binding. | aria-label is not a DOM property, so the binding errors or is ignored. | Use attribute binding: [attr.aria-label]="label". |
Router navigation that never moves focus. | Keyboard and screen reader users are stranded on the old view (2.4.3). | Focus <main> or the new <h1> on NavigationEnd. |
Results/toasts updated by change detection alone. | Screen readers never hear the change (4.1.3 Status Messages). | Announce with LiveAnnouncer or an aria-live region. |
Custom overlay without a focus trap. | Focus escapes behind the dialog; users get lost (2.1.2, 2.4.3). | Add cdkTrapFocus, or use MatDialog / CDK Dialog. |
Reactive-form errors not tied to their input. | Screen reader users hear the field but not why it failed (3.3.1). | Wire [attr.aria-invalid] and [attr.aria-describedby] to the message. |
Angular Accessibility Checklist
- Semantic templates. Every clickable thing is a
<button>or<a>; headings are ordered; one<h1>per view. - ARIA binding. Dynamic ARIA uses
[attr.aria-*]and returnsnullto remove attributes when off. - Focus on navigation.
NavigationEndmoves focus to<main>or the new heading. - Overlays. Dialogs trap focus (
cdkTrapFocus/ MatDialog), close on Escape, and restore focus. - Live regions. Async results, toasts, and errors are announced via
LiveAnnouncer. - Forms. Labels associated; errors linked with
[attr.aria-describedby]and[attr.aria-invalid]. - Names. Icon-only buttons have a name; decorative images use
alt="". - Automated + manual. @angular-eslint + jasmine-axe in CI, plus a keyboard and screen reader pass.
Scan the deployed build with our URL accessibility auditor and work through the full WCAG 2.2 checklist.
Audit Your Angular App in Seconds
Run any deployed Angular page through our free axe-core-powered auditor to catch missing names, unlabeled controls, and contrast failures — then work through the manual checks above.
Frequently Asked Questions
Is Angular accessible by default?▾
Angular is neutral — it renders whatever you write in the template. If you use semantic elements (button, a, nav, label, input, h1–h6), your app inherits the keyboard behavior and screen reader semantics those elements already ship with. Accessibility problems come from Angular patterns, not the framework: div-and-span elements with (click) handlers, router navigation that never moves focus, change detection that updates the DOM without announcing it, and custom components that reimplement native controls without keyboard support. Angular can be fully WCAG 2.2 AA accessible, and it even ships a dedicated accessibility toolkit in @angular/cdk/a11y — but accessibility is something you build in, not something you get for free.
Why do I have to use [attr.aria-*] instead of [aria-*] in Angular?▾
ARIA attributes are not DOM properties, so Angular's property binding syntax can't reach them — writing [aria-label]="value" produces a template error because there is no aria-label property to bind to. The documented, reliable way to set ARIA values dynamically is attribute binding: [attr.aria-label]="value", [attr.aria-expanded]="open", [attr.role]="role". Attribute binding has a bonus that is perfect for accessibility: when the bound expression evaluates to null, Angular removes the attribute entirely. That lets you write [attr.aria-invalid]="invalid ? 'true' : null" so the attribute only appears when there is genuinely an error, instead of shipping aria-invalid="false" everywhere. Static ARIA values that never change can still be written as plain attributes.
How do I move focus when the route changes in an Angular app?▾
The Angular Router swaps the routed component without a full page load, so the browser never resets focus the way a traditional navigation would. Keyboard and screen reader users are left on the link they activated, now pointing at content that no longer exists. Fix it by subscribing to the Router's NavigationEnd event (or the router-outlet's (activate) event) and moving focus to the top of the new view — typically a <main> element or the page <h1> given tabindex="-1" so it can receive programmatic focus. Pair that with a skip link to the main region. This satisfies WCAG 2.4.3 Focus Order.
What is @angular/cdk/a11y and what does it give me?▾
@angular/cdk/a11y is the Angular Component Dev Kit's accessibility package — a set of framework-level primitives you would otherwise have to build by hand. The most useful pieces are LiveAnnouncer (announce messages to screen readers through a managed aria-live region), the cdkTrapFocus directive (trap keyboard focus inside a region such as a dialog, and optionally restore it on destroy), FocusMonitor (detect whether an element was focused by keyboard, mouse, or touch so you can style keyboard focus distinctly), and cdkAriaLive plus HighContrastModeDetector. You can use these without Angular Material — the CDK is the accessibility layer Material itself is built on.
How do I announce dynamic updates to screen readers in Angular?▾
Because change detection updates the DOM silently, a screen reader hears nothing when results load, a toast appears, or a form saves — unless the change happens inside an aria-live region. You have two idiomatic options. Inject LiveAnnouncer from @angular/cdk/a11y and call announce('12 results found', 'polite'); it manages a visually hidden live region for you. Or render your own element with aria-live="polite" that already exists in the DOM before its text changes, and update the bound text. Reserve aria-live="assertive" (or the 'assertive' politeness) for urgent, interrupting messages such as a session-timeout warning. This satisfies WCAG 4.1.3 Status Messages.
How do I test an Angular app for accessibility?▾
Use three layers. First, lint the templates: the @angular-eslint template plugin ships accessibility rules such as click-events-have-key-events, label-has-associated-control, valid-aria, and elements-content that catch many issues as you type. Second, component tests: render components with the Angular Testing Library — which encourages accessible queries like getByRole and getByLabelText — and assert with jasmine-axe's toHaveNoViolations matcher. Third, end-to-end: run axe-core through @axe-core/playwright against real routes. None of these replace manual keyboard and screen reader testing, which is the only way to confirm the experience actually works.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences