Skip to main content

Angular Accessibility: Building Inclusive Applications with Angular

Angular is one of the most fully-featured frameworks for building accessible web applications. With its component-based architecture, powerful template system, and the dedicated CDK (Component Dev Kit) accessibility module, Angular provides tools that make it significantly easier to implement WCAG-compliant interfaces. However, these tools only work when developers understand how to use them correctly and avoid the framework-specific pitfalls that can undermine accessibility.

This guide covers Angular's accessibility capabilities in depth — from the CDK a11y module and Angular Material components through to reactive forms, routing, testing, and the newer Signals API. Each section includes practical TypeScript and template examples you can apply directly to your projects.

The Angular CDK a11y Module

The @angular/cdk/a11y module is the foundation of accessibility tooling in Angular. It provides a set of utilities that solve common accessibility challenges such as focus management, live announcements, and ARIA description handling. Unlike Angular Material, the CDK carries no visual opinion — it is pure behavioral infrastructure you can use with any design system.

To install the CDK, add it as a dependency:

npm install @angular/cdk

Then import the A11yModule in your module or standalone component:

import { A11yModule } from '@angular/cdk/a11y';

@Component({
  standalone: true,
  imports: [A11yModule],
  // ...
})
export class MyComponent {}

FocusTrap: Managing Focus Within Containers

The cdkTrapFocus directive confines keyboard focus to a specific container element. This is essential for modal dialogs, sidebars, and any overlay where focus must not escape to the content behind. Without focus trapping, keyboard and screen reader users can navigate to elements hidden behind a modal, creating a confusing and disorienting experience.

<div class="modal-overlay" cdkTrapFocus [cdkTrapFocusAutoCapture]="true">
  <div class="modal-content" role="dialog" aria-labelledby="modal-title">
    <h2 id="modal-title">Confirm Deletion</h2>
    <p>Are you sure you want to delete this item? This action cannot be undone.</p>
    <div class="modal-actions">
      <button (click)="cancel()">Cancel</button>
      <button (click)="confirm()">Delete</button>
    </div>
  </div>
</div>

The [cdkTrapFocusAutoCapture]="true" binding tells the trap to immediately move focus into the container when it activates, and restore focus to the previously focused element when the trap is destroyed. This handles the full lifecycle of focus for overlays — capturing on open and restoring on close.

You can also use the FocusTrap class programmatically when you need more control:

import { FocusTrapFactory } from '@angular/cdk/a11y';

@Component({ /* ... */ })
export class DrawerComponent implements AfterViewInit, OnDestroy {
  @ViewChild('drawerContent') drawerContent!: ElementRef;
  private focusTrap!: FocusTrap;

  constructor(private focusTrapFactory: FocusTrapFactory) {}

  ngAfterViewInit() {
    this.focusTrap = this.focusTrapFactory.create(
      this.drawerContent.nativeElement
    );
    this.focusTrap.focusInitialElementWhenReady();
  }

  ngOnDestroy() {
    this.focusTrap.destroy();
  }
}

FocusMonitor: Tracking Focus Origin

The FocusMonitor service detects how an element received focus — whether through keyboard navigation, mouse click, touch, or programmatic focus. This distinction is critical for showing focus indicators only when appropriate. Keyboard users need visible focus rings, but mouse users typically find them distracting. The FocusMonitor lets you differentiate.

import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';

@Component({
  selector: 'app-custom-button',
  template: `
    <button #btn
      [class.keyboard-focus]="focusOrigin === 'keyboard'"
      [class.mouse-focus]="focusOrigin === 'mouse'">
      <ng-content></ng-content>
    </button>
  `
})
export class CustomButtonComponent implements AfterViewInit, OnDestroy {
  @ViewChild('btn') buttonRef!: ElementRef;
  focusOrigin: FocusOrigin | null = null;

  constructor(private focusMonitor: FocusMonitor) {}

  ngAfterViewInit() {
    this.focusMonitor.monitor(this.buttonRef, true)
      .subscribe((origin: FocusOrigin) => {
        this.focusOrigin = origin;
      });
  }

  ngOnDestroy() {
    this.focusMonitor.stopMonitoring(this.buttonRef);
  }
}

The second parameter true in the monitor() call enables subtree monitoring — it tracks focus changes on all descendants of the element, not just the element itself. This is useful for composite components like toolbars or menu bars where you want to track focus across multiple child elements.

You can also use the cdkMonitorElementFocus or cdkMonitorSubtreeFocus directives in templates for simpler scenarios:

<button cdkMonitorElementFocus
        (cdkFocusChange)="onFocusChange($event)">
  Click me
</button>

LiveAnnouncer: Screen Reader Announcements

The LiveAnnouncer service creates ARIA live region announcements programmatically. This is essential for communicating dynamic changes — such as form submission results, data loading states, or filter results — to screen reader users who cannot see visual changes on the page.

import { LiveAnnouncer } from '@angular/cdk/a11y';

@Component({ /* ... */ })
export class SearchResultsComponent {
  constructor(private liveAnnouncer: LiveAnnouncer) {}

  onSearchResults(results: SearchResult[]) {
    if (results.length === 0) {
      this.liveAnnouncer.announce(
        'No results found. Try adjusting your search terms.',
        'assertive'
      );
    } else {
      this.liveAnnouncer.announce(
        `${results.length} results found.`,
        'polite'
      );
    }
  }

  onItemDeleted(item: Item) {
    this.liveAnnouncer.announce(
      `${item.name} has been deleted.`,
      'assertive'
    );
  }

  onSortChanged(column: string, direction: string) {
    this.liveAnnouncer.announce(
      `Table sorted by ${column}, ${direction} order.`,
      'polite'
    );
  }
}

Use 'polite' for non-urgent updates that should wait until the user is idle, and 'assertive' for time-sensitive information like errors or destructive action confirmations. Overusing 'assertive' interrupts the user's current activity and creates a poor experience.

AriaDescriber: Managing aria-describedby

The AriaDescriber service manages aria-describedby associations efficiently. It creates shared description elements and handles deduplication automatically, so if multiple elements share the same description text, only one hidden element is created in the DOM.

import { AriaDescriber } from '@angular/cdk/a11y';

@Component({ /* ... */ })
export class TooltipComponent implements AfterViewInit, OnDestroy {
  @ViewChild('triggerElement') trigger!: ElementRef;

  constructor(private ariaDescriber: AriaDescriber) {}

  ngAfterViewInit() {
    this.ariaDescriber.describe(
      this.trigger.nativeElement,
      'Opens a new window'
    );
  }

  ngOnDestroy() {
    this.ariaDescriber.removeDescription(
      this.trigger.nativeElement,
      'Opens a new window'
    );
  }
}

This is particularly useful for tooltips, help text, and supplementary descriptions that multiple elements might share.

Angular Material and Accessibility

Angular Material components are built on top of the CDK and come with extensive accessibility features out of the box. They include proper ARIA roles, keyboard interactions, focus management, and high-contrast theme support. However, they still require developers to supply contextual information — a Material button knows it is a button, but it does not know what it does.

<!-- Good: Angular Material button with accessible label -->
<button mat-icon-button aria-label="Close dialog">
  <mat-icon>close</mat-icon>
</button>

<!-- Bad: No accessible label for icon-only button -->
<button mat-icon-button>
  <mat-icon>close</mat-icon>
</button>

<!-- Good: Form field with proper labeling -->
<mat-form-field>
  <mat-label>Email address</mat-label>
  <input matInput type="email" formControlName="email">
  <mat-hint>We will never share your email.</mat-hint>
  <mat-error *ngIf="emailControl.hasError('required')">
    Email is required.
  </mat-error>
  <mat-error *ngIf="emailControl.hasError('email')">
    Please enter a valid email address.
  </mat-error>
</mat-form-field>

Angular Material's mat-form-field automatically associates the mat-label with the input via aria-labelledby, connects mat-error elements through aria-describedby, and manages the visibility of error messages based on the form control's validation state. This is a significant amount of accessibility wiring that you get for free.

Key Angular Material components with notable accessibility features include:

  • MatSelect — Full listbox keyboard navigation (arrow keys, type-ahead search, Home/End), proper role="listbox" and role="option" markup.
  • MatTable with MatSort — Sort headers are announced as buttons, sort direction is communicated via aria-sort, and changes are announced via LiveAnnouncer.
  • MatDialog — Automatic focus trapping, focus restoration on close, Escape key handling, and proper role="dialog" with aria-labelledby.
  • MatSnackBar — Announced via aria-live regions, with configurable politeness level.
  • MatAutocomplete — Implements the combobox pattern with role="combobox", aria-expanded, aria-activedescendant, and full keyboard support.
  • MatSidenav — Focus trapping in push/over modes, backdrop click handling, Escape key support.

Template-Driven Accessibility Bindings

Angular's template syntax makes it straightforward to bind ARIA attributes dynamically. The [attr.aria-*] binding syntax is the primary pattern for setting ARIA attributes on elements.

<button
  [attr.aria-expanded]="isMenuOpen"
  [attr.aria-controls]="menuId"
  [attr.aria-haspopup]="'menu'"
  (click)="toggleMenu()">
  Options
</button>

<ul
  [id]="menuId"
  role="menu"
  *ngIf="isMenuOpen">
  <li role="menuitem" *ngFor="let item of menuItems">
    <button (click)="selectItem(item)">{{ item.label }}</button>
  </li>
</ul>

An important distinction in Angular templates is between *ngIf and the [hidden] attribute for controlling visibility. They have different accessibility implications:

<!-- *ngIf removes the element from the DOM entirely.
     Screen readers cannot find it at all. Use this when the content
     should not exist until a condition is met. -->
<div *ngIf="showDetails" role="region" aria-label="Details">
  <p>Additional information appears here.</p>
</div>

<!-- [hidden] keeps the element in the DOM but hides it visually
     and from assistive tech. Use this when you want the element
     to remain in the DOM for reference (e.g., aria-controls target). -->
<div [hidden]="!showDetails" role="region" aria-label="Details">
  <p>Additional information appears here.</p>
</div>

<!-- For content that should be visually hidden but readable
     by screen readers, use a CSS utility class -->
<span class="sr-only">{{ accessibleLabel }}</span>

With Angular 17+ and the new control flow syntax, the same principle applies:

<!-- @if removes from DOM, like *ngIf -->
@if (showPanel) {
  <div role="region" aria-label="Filter panel">
    <!-- panel content -->
  </div>
}

<!-- For toggle patterns where the target must exist for
     aria-controls, prefer [hidden] or CSS approach -->
<button [attr.aria-controls]="'panel-' + id"
        [attr.aria-expanded]="isOpen">
  Toggle Panel
</button>
<div [id]="'panel-' + id" [hidden]="!isOpen">
  Panel content
</div>

When using [attr.role], be careful with conditional roles. Setting a role to null removes the attribute entirely, which is usually what you want:

<div [attr.role]="isInteractive ? 'button' : null"
     [attr.tabindex]="isInteractive ? 0 : null"
     (keydown.enter)="isInteractive && activate()"
     (keydown.space)="isInteractive && activate()">
  {{ content }}
</div>

Router Accessibility: Route Announcements and Focus Management

Single-page applications present a unique accessibility challenge: when the user navigates to a new route, the browser does not perform a full page load, so screen readers are not automatically informed that the page has changed. Angular provides several mechanisms to address this.

Angular's router has a built-in TitleStrategy that sets the document title on navigation. Since Angular 14, you can set titles directly in route definitions:

const routes: Routes = [
  {
    path: '',
    title: 'Home — My App',
    component: HomeComponent
  },
  {
    path: 'products',
    title: 'Products — My App',
    component: ProductListComponent
  },
  {
    path: 'products/:id',
    title: ProductTitleResolver,
    component: ProductDetailComponent
  }
];

// Custom title resolver for dynamic titles
@Injectable({ providedIn: 'root' })
export class ProductTitleResolver implements Resolve<string> {
  constructor(private productService: ProductService) {}

  resolve(route: ActivatedRouteSnapshot): Observable<string> {
    return this.productService.getProduct(route.params['id']).pipe(
      map(product => `${product.name} — My App`)
    );
  }
}

For a custom title strategy that also announces route changes to screen readers:

import { TitleStrategy } from '@angular/router';
import { LiveAnnouncer } from '@angular/cdk/a11y';

@Injectable({ providedIn: 'root' })
export class AccessibleTitleStrategy extends TitleStrategy {
  constructor(
    private title: Title,
    private liveAnnouncer: LiveAnnouncer
  ) {
    super();
  }

  override updateTitle(routerState: RouterStateSnapshot): void {
    const title = this.buildTitle(routerState);
    if (title) {
      this.title.setTitle(title);
      this.liveAnnouncer.announce(`Navigated to ${title}`, 'polite');
    }
  }
}

// Register in providers
bootstrapApplication(AppComponent, {
  providers: [
    provideRouter(routes),
    { provide: TitleStrategy, useClass: AccessibleTitleStrategy }
  ]
});

Focus management after navigation is equally important. When a screen reader user navigates to a new page, focus should move to a logical location — typically the main content heading or the main content container:

@Component({ /* ... */ })
export class AppComponent implements OnInit {
  constructor(
    private router: Router,
    private viewportScroller: ViewportScroller
  ) {}

  ngOnInit() {
    this.router.events
      .pipe(filter(event => event instanceof NavigationEnd))
      .subscribe(() => {
        // Scroll to top
        this.viewportScroller.scrollToPosition([0, 0]);

        // Move focus to main content heading
        setTimeout(() => {
          const heading = document.querySelector('main h1') as HTMLElement;
          if (heading) {
            heading.setAttribute('tabindex', '-1');
            heading.focus();
            // Remove tabindex after blur to avoid persistent tab stop
            heading.addEventListener('blur', () => {
              heading.removeAttribute('tabindex');
            }, { once: true });
          }
        });
      });
  }
}

The ViewportScroller service handles scroll position restoration. Configure it through the router:

provideRouter(
  routes,
  withInMemoryScrolling({
    scrollPositionRestoration: 'top',
    anchorScrolling: 'enabled'
  })
)

Reactive Forms Accessibility

Angular's reactive forms provide strong programmatic control over form state, but they require deliberate effort to make accessible. The critical requirements are: every form control must have a visible label, validation errors must be programmatically associated with their controls, and error messages must be announced when they appear.

// Component
@Component({
  selector: 'app-registration-form',
  templateUrl: './registration-form.component.html'
})
export class RegistrationFormComponent {
  registrationForm = new FormGroup({
    name: new FormControl('', [
      Validators.required,
      Validators.minLength(2)
    ]),
    email: new FormControl('', [
      Validators.required,
      Validators.email
    ]),
    password: new FormControl('', [
      Validators.required,
      Validators.minLength(8),
      this.passwordStrengthValidator
    ]),
    acceptTerms: new FormControl(false, [
      Validators.requiredTrue
    ])
  });

  constructor(private liveAnnouncer: LiveAnnouncer) {}

  onSubmit() {
    if (this.registrationForm.valid) {
      // Submit form
    } else {
      this.registrationForm.markAllAsTouched();
      const errorCount = this.countErrors();
      this.liveAnnouncer.announce(
        `Form has ${errorCount} error${errorCount !== 1 ? 's' : ''}. Please review and correct.`,
        'assertive'
      );
      // Focus first invalid field
      const firstInvalid = document.querySelector(
        'form .ng-invalid input, form .ng-invalid select, form .ng-invalid textarea'
      ) as HTMLElement;
      firstInvalid?.focus();
    }
  }

  private countErrors(): number {
    return Object.values(this.registrationForm.controls)
      .filter(control => control.invalid).length;
  }
}

The template must associate error messages with their respective form controls using aria-describedby:

<form [formGroup]="registrationForm" (ngSubmit)="onSubmit()">

  <div class="form-field">
    <label for="name">Full name</label>
    <input
      id="name"
      type="text"
      formControlName="name"
      [attr.aria-invalid]="registrationForm.get('name')?.invalid
                           && registrationForm.get('name')?.touched"
      [attr.aria-describedby]="registrationForm.get('name')?.invalid
                               && registrationForm.get('name')?.touched
                               ? 'name-errors' : null">
    <div id="name-errors"
         role="alert"
         *ngIf="registrationForm.get('name')?.invalid
                && registrationForm.get('name')?.touched">
      <p *ngIf="registrationForm.get('name')?.hasError('required')">
        Full name is required.
      </p>
      <p *ngIf="registrationForm.get('name')?.hasError('minlength')">
        Name must be at least 2 characters.
      </p>
    </div>
  </div>

  <div class="form-field">
    <label for="email">Email address</label>
    <input
      id="email"
      type="email"
      formControlName="email"
      [attr.aria-invalid]="registrationForm.get('email')?.invalid
                           && registrationForm.get('email')?.touched"
      [attr.aria-describedby]="registrationForm.get('email')?.invalid
                               && registrationForm.get('email')?.touched
                               ? 'email-errors' : 'email-hint'">
    <p id="email-hint">We will never share your email with anyone.</p>
    <div id="email-errors"
         role="alert"
         *ngIf="registrationForm.get('email')?.invalid
                && registrationForm.get('email')?.touched">
      <p *ngIf="registrationForm.get('email')?.hasError('required')">
        Email is required.
      </p>
      <p *ngIf="registrationForm.get('email')?.hasError('email')">
        Please enter a valid email address.
      </p>
    </div>
  </div>

  <div class="form-field">
    <label for="password">Password</label>
    <input
      id="password"
      type="password"
      formControlName="password"
      [attr.aria-invalid]="registrationForm.get('password')?.invalid
                           && registrationForm.get('password')?.touched"
      [attr.aria-describedby]="'password-requirements'
                               + (registrationForm.get('password')?.invalid
                                  && registrationForm.get('password')?.touched
                                  ? ' password-errors' : '')">
    <p id="password-requirements">
      Must be at least 8 characters with one uppercase letter and one number.
    </p>
    <div id="password-errors"
         role="alert"
         *ngIf="registrationForm.get('password')?.invalid
                && registrationForm.get('password')?.touched">
      <p *ngIf="registrationForm.get('password')?.hasError('required')">
        Password is required.
      </p>
      <p *ngIf="registrationForm.get('password')?.hasError('minlength')">
        Password must be at least 8 characters.
      </p>
    </div>
  </div>

  <div class="form-field">
    <label>
      <input type="checkbox" formControlName="acceptTerms"
             [attr.aria-invalid]="registrationForm.get('acceptTerms')?.invalid
                                  && registrationForm.get('acceptTerms')?.touched">
      I accept the terms and conditions
    </label>
  </div>

  <button type="submit">Create Account</button>

</form>

Note the use of role="alert" on the error containers. This ensures screen readers announce errors immediately when they appear, without requiring a separate LiveAnnouncer call for each field. However, be careful with role="alert" — if many fields show errors simultaneously (like on form submission), the user may hear a cascade of announcements. For bulk validation, the LiveAnnouncer summary approach shown in the component code is preferable.

Component Communication and ARIA: @Input for Accessibility

Angular's @Input decorator makes it natural to pass ARIA attributes into reusable components. This is a powerful pattern for building component libraries where the parent component knows the semantic context and the child component renders the correct ARIA attributes.

@Component({
  selector: 'app-icon-button',
  template: `
    <button
      [attr.aria-label]="ariaLabel"
      [attr.aria-pressed]="ariaPressed"
      [attr.aria-expanded]="ariaExpanded"
      [attr.aria-controls]="ariaControls"
      [disabled]="disabled"
      (click)="clicked.emit($event)">
      <ng-content></ng-content>
    </button>
  `
})
export class IconButtonComponent {
  @Input({ required: true }) ariaLabel!: string;
  @Input() ariaPressed: boolean | null = null;
  @Input() ariaExpanded: boolean | null = null;
  @Input() ariaControls: string | null = null;
  @Input() disabled = false;
  @Output() clicked = new EventEmitter<MouseEvent>();
}

Usage in a parent template:

<app-icon-button
  ariaLabel="Toggle favorites"
  [ariaPressed]="isFavorite"
  (clicked)="toggleFavorite()">
  <svg><!-- heart icon --></svg>
</app-icon-button>

<app-icon-button
  ariaLabel="Show filters"
  [ariaExpanded]="filtersOpen"
  ariaControls="filter-panel"
  (clicked)="toggleFilters()">
  <svg><!-- filter icon --></svg>
</app-icon-button>

The { required: true } option on the ariaLabel input enforces at compile time that consumers must provide a label. This prevents the common mistake of deploying icon buttons without accessible names.

For components that need to set ARIA attributes on their host element, use @HostBinding:

@Component({
  selector: 'app-accordion-panel',
  template: `
    <button
      [id]="headerId"
      [attr.aria-expanded]="isOpen"
      [attr.aria-controls]="panelId"
      (click)="toggle()">
      <ng-content select="[accordion-header]"></ng-content>
    </button>
    <div
      [id]="panelId"
      [attr.aria-labelledby]="headerId"
      [hidden]="!isOpen">
      <ng-content select="[accordion-body]"></ng-content>
    </div>
  `
})
export class AccordionPanelComponent {
  @Input() panelId = '';
  @HostBinding('attr.role') role = 'region';

  isOpen = false;

  get headerId() { return this.panelId + '-header'; }

  toggle() { this.isOpen = !this.isOpen; }
}

Angular CDK Overlays: Accessible Dialogs and Dropdown Panels

The CDK Overlay system is the foundation for any content that floats above the main page — dialogs, dropdown menus, tooltips, popovers, and autocomplete panels. Building accessible overlays requires proper focus management, keyboard interaction, and ARIA relationships.

import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';

@Component({ /* ... */ })
export class DropdownTriggerComponent {
  private overlayRef: OverlayRef | null = null;

  constructor(
    private overlay: Overlay,
    private elementRef: ElementRef,
    private liveAnnouncer: LiveAnnouncer
  ) {}

  openDropdown() {
    const positionStrategy = this.overlay
      .position()
      .flexibleConnectedTo(this.elementRef)
      .withPositions([
        {
          originX: 'start',
          originY: 'bottom',
          overlayX: 'start',
          overlayY: 'top'
        }
      ]);

    this.overlayRef = this.overlay.create({
      positionStrategy,
      hasBackdrop: true,
      backdropClass: 'cdk-overlay-transparent-backdrop',
      // Keyboard event handling
      disposeOnNavigation: true
    });

    const portal = new ComponentPortal(DropdownMenuComponent);
    const componentRef = this.overlayRef.attach(portal);

    // Close on backdrop click
    this.overlayRef.backdropClick().subscribe(() => this.close());

    // Close on Escape key
    this.overlayRef.keydownEvents()
      .pipe(filter(event => event.key === 'Escape'))
      .subscribe(() => {
        this.close();
        // Return focus to trigger element
        this.elementRef.nativeElement.focus();
      });

    this.liveAnnouncer.announce('Menu opened', 'polite');
  }

  close() {
    this.overlayRef?.dispose();
    this.overlayRef = null;
  }
}

For dialog-style overlays, the CDK provides the Dialog service (distinct from Angular Material's MatDialog) that handles focus trapping, restoration, and ARIA attributes:

import { Dialog } from '@angular/cdk/dialog';

@Component({ /* ... */ })
export class AppComponent {
  constructor(private dialog: Dialog) {}

  openConfirmDialog() {
    const dialogRef = this.dialog.open(ConfirmDialogComponent, {
      width: '400px',
      ariaLabel: 'Confirm deletion',
      ariaDescribedBy: 'confirm-dialog-description',
      autoFocus: 'first-tabbable',
      restoreFocus: true,
      disableClose: false // Allows Escape key
    });

    dialogRef.closed.subscribe(result => {
      if (result === 'confirm') {
        this.deleteItem();
      }
    });
  }
}

The autoFocus option controls where focus lands when the dialog opens. Options include 'first-tabbable' (the first focusable element), 'first-heading' (the first heading element), 'dialog' (the dialog container itself), or a CSS selector string. For destructive confirmation dialogs, focusing the cancel button rather than the confirm button is a safer accessibility practice.

Testing Angular Accessibility

Accessibility testing in Angular should happen at multiple levels: unit tests for component ARIA behavior, integration tests for keyboard interactions, and end-to-end tests for full user flows. Angular's testing utilities work well with dedicated accessibility testing libraries.

For unit testing ARIA attributes and focus behavior with Angular's TestBed:

describe('IconButtonComponent', () => {
  let fixture: ComponentFixture<IconButtonComponent>;
  let component: IconButtonComponent;
  let buttonEl: HTMLButtonElement;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [IconButtonComponent]
    }).compileComponents();

    fixture = TestBed.createComponent(IconButtonComponent);
    component = fixture.componentInstance;
    component.ariaLabel = 'Delete item';
    fixture.detectChanges();
    buttonEl = fixture.nativeElement.querySelector('button');
  });

  it('should set aria-label on the button', () => {
    expect(buttonEl.getAttribute('aria-label')).toBe('Delete item');
  });

  it('should set aria-pressed when provided', () => {
    component.ariaPressed = true;
    fixture.detectChanges();
    expect(buttonEl.getAttribute('aria-pressed')).toBe('true');
  });

  it('should not render aria-pressed when null', () => {
    component.ariaPressed = null;
    fixture.detectChanges();
    expect(buttonEl.hasAttribute('aria-pressed')).toBeFalse();
  });

  it('should be focusable', () => {
    buttonEl.focus();
    expect(document.activeElement).toBe(buttonEl);
  });
});

For integrating axe-core into Angular tests, use the jest-axe or jasmine-axe library:

import { axe, toHaveNoViolations } from 'jasmine-axe';

describe('RegistrationFormComponent accessibility', () => {
  let fixture: ComponentFixture<RegistrationFormComponent>;

  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [RegistrationFormComponent, ReactiveFormsModule]
    }).compileComponents();

    fixture = TestBed.createComponent(RegistrationFormComponent);
    fixture.detectChanges();
    jasmine.addMatchers(toHaveNoViolations);
  });

  it('should have no accessibility violations in initial state', async () => {
    const results = await axe(fixture.nativeElement);
    expect(results).toHaveNoViolations();
  });

  it('should have no violations when showing errors', async () => {
    // Trigger validation
    fixture.componentInstance.registrationForm.markAllAsTouched();
    fixture.detectChanges();

    const results = await axe(fixture.nativeElement);
    expect(results).toHaveNoViolations();
  });

  it('should associate error messages with inputs', () => {
    const component = fixture.componentInstance;
    component.registrationForm.markAllAsTouched();
    fixture.detectChanges();

    const emailInput = fixture.nativeElement.querySelector('#email');
    const describedBy = emailInput.getAttribute('aria-describedby');
    expect(describedBy).toContain('email-errors');

    const errorElement = fixture.nativeElement.querySelector('#email-errors');
    expect(errorElement).toBeTruthy();
    expect(errorElement.getAttribute('role')).toBe('alert');
  });
});

For end-to-end testing with Playwright, integrate axe-core via @axe-core/playwright:

import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';

test.describe('Registration page accessibility', () => {
  test('should have no accessibility violations', async ({ page }) => {
    await page.goto('/register');

    const accessibilityScanResults = await new AxeBuilder({ page })
      .withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
      .analyze();

    expect(accessibilityScanResults.violations).toEqual([]);
  });

  test('should be keyboard navigable', async ({ page }) => {
    await page.goto('/register');

    // Tab to first form field
    await page.keyboard.press('Tab');
    const focusedElement = await page.evaluate(() =>
      document.activeElement?.id
    );
    expect(focusedElement).toBe('name');

    // Tab through all fields
    await page.keyboard.press('Tab');
    expect(await page.evaluate(() => document.activeElement?.id))
      .toBe('email');

    // Submit with Enter
    await page.keyboard.press('Tab'); // password
    await page.keyboard.press('Tab'); // checkbox
    await page.keyboard.press('Tab'); // submit button
    await page.keyboard.press('Enter');

    // Check that errors are announced
    const alertElements = await page.locator('[role="alert"]').all();
    expect(alertElements.length).toBeGreaterThan(0);
  });

  test('should announce route changes', async ({ page }) => {
    await page.goto('/');

    // Navigate to registration
    await page.click('a[href="/register"]');

    // Verify the live region was updated
    const liveRegion = page.locator('[aria-live]');
    await expect(liveRegion).toContainText('Navigated to');
  });
});

For projects still using Protractor (legacy Angular e2e testing), accessibility testing follows a similar pattern using axe-core directly:

import { browser, element, by } from 'protractor';

const axeBuilder = require('@axe-core/webdriverjs');

describe('App accessibility', () => {
  it('should pass axe checks on the home page', async () => {
    await browser.get('/');
    const results = await new axeBuilder(browser.driver)
      .withTags(['wcag2a', 'wcag2aa'])
      .analyze();
    expect(results.violations.length).toBe(0);
  });
});

Note that Protractor has been deprecated since Angular 12. New projects should use Playwright, Cypress, or WebdriverIO for end-to-end testing.

Custom Directives for Focus Management

Angular directives are an excellent mechanism for encapsulating reusable accessibility behavior. Instead of repeating focus management logic across components, extract it into directives that can be applied declaratively.

A directive for auto-focusing an element when it appears:

@Directive({
  selector: '[appAutoFocus]',
  standalone: true
})
export class AutoFocusDirective implements AfterViewInit {
  @Input() appAutoFocus: boolean | '' = true;
  @Input() autoFocusDelay = 0;

  constructor(private elementRef: ElementRef) {}

  ngAfterViewInit() {
    const shouldFocus = this.appAutoFocus === '' || this.appAutoFocus;
    if (shouldFocus) {
      setTimeout(() => {
        this.elementRef.nativeElement.focus();
      }, this.autoFocusDelay);
    }
  }
}

// Usage
// <input appAutoFocus />
// <input [appAutoFocus]="isEditing" />

A directive for managing roving tabindex, commonly used in toolbars, tab lists, and menu bars:

@Directive({
  selector: '[appRovingTabindex]',
  standalone: true
})
export class RovingTabindexDirective implements AfterContentInit {
  @ContentChildren('rovingItem', { descendants: true, read: ElementRef })
  items!: QueryList<ElementRef>;

  private activeIndex = 0;

  ngAfterContentInit() {
    this.updateTabindices();
    this.items.changes.subscribe(() => this.updateTabindices());
  }

  @HostListener('keydown', ['$event'])
  onKeydown(event: KeyboardEvent) {
    const itemArray = this.items.toArray();
    let handled = false;

    switch (event.key) {
      case 'ArrowRight':
      case 'ArrowDown':
        this.activeIndex = (this.activeIndex + 1) % itemArray.length;
        handled = true;
        break;
      case 'ArrowLeft':
      case 'ArrowUp':
        this.activeIndex =
          (this.activeIndex - 1 + itemArray.length) % itemArray.length;
        handled = true;
        break;
      case 'Home':
        this.activeIndex = 0;
        handled = true;
        break;
      case 'End':
        this.activeIndex = itemArray.length - 1;
        handled = true;
        break;
    }

    if (handled) {
      event.preventDefault();
      this.updateTabindices();
      itemArray[this.activeIndex].nativeElement.focus();
    }
  }

  private updateTabindices() {
    this.items.forEach((item, index) => {
      item.nativeElement.setAttribute(
        'tabindex',
        index === this.activeIndex ? '0' : '-1'
      );
    });
  }
}

// Usage
// <div role="toolbar" aria-label="Text formatting" appRovingTabindex>
//   <button #rovingItem>Bold</button>
//   <button #rovingItem>Italic</button>
//   <button #rovingItem>Underline</button>
// </div>

A directive for creating live regions that announce dynamic content changes:

@Directive({
  selector: '[appLiveRegion]',
  standalone: true
})
export class LiveRegionDirective implements OnInit, OnDestroy {
  @Input() appLiveRegion: 'polite' | 'assertive' = 'polite';
  @Input() liveRegionAtomic = true;

  private observer: MutationObserver | null = null;

  constructor(private elementRef: ElementRef) {}

  ngOnInit() {
    const el = this.elementRef.nativeElement;
    el.setAttribute('aria-live', this.appLiveRegion);
    el.setAttribute('aria-atomic', String(this.liveRegionAtomic));
  }

  ngOnDestroy() {
    this.observer?.disconnect();
  }
}

// Usage
// <div appLiveRegion="polite">
//   {{ statusMessage }}
// </div>
//
// <div appLiveRegion="assertive">
//   {{ errorMessage }}
// </div>

Server-Side Rendering (Angular Universal) and Accessibility

Angular Universal (now Angular SSR, integrated into the Angular CLI since version 17) renders Angular applications on the server, producing static HTML that is sent to the browser before the JavaScript bundle loads and hydrates the page. This has important accessibility implications.

The primary accessibility benefit of SSR is that content is available in the DOM immediately. Screen readers and other assistive technologies can begin reading the page before Angular's client-side JavaScript has loaded and bootstrapped. For users on slow connections or underpowered devices, this can be the difference between an accessible and inaccessible experience.

However, SSR introduces specific challenges that developers must address:

  • Avoid direct DOM manipulation — SSR renders on Node.js where document and window do not exist. Accessibility code that accesses the DOM directly (such as document.querySelector for focus management) will fail on the server. Always guard DOM access with platform checks or use Angular's abstractions.
  • Hydration mismatches — If the server-rendered HTML and the client-rendered HTML differ, Angular's hydration will produce warnings or re-render content. Dynamic ARIA attributes (like aria-expanded on a menu that defaults to closed) can cause mismatches if the server and client compute different initial states.
  • Interactive states — Elements that require JavaScript for keyboard interaction (custom dropdowns, dialogs) are inert during the SSR phase. Ensure that the server-rendered state communicates non-interactivity appropriately or provides fallback content.
import { isPlatformBrowser } from '@angular/common';
import { PLATFORM_ID } from '@angular/core';

@Component({ /* ... */ })
export class AccessibleMenuComponent implements AfterViewInit {
  private isBrowser: boolean;

  constructor(
    @Inject(PLATFORM_ID) platformId: object,
    private liveAnnouncer: LiveAnnouncer
  ) {
    this.isBrowser = isPlatformBrowser(platformId);
  }

  ngAfterViewInit() {
    // Only run focus management code in the browser
    if (this.isBrowser) {
      this.setupKeyboardNavigation();
    }
  }

  private setupKeyboardNavigation() {
    // Safe to access DOM here
  }
}

With Angular 17+ and the new afterNextRender lifecycle hook, you can more cleanly separate browser-only code:

@Component({ /* ... */ })
export class TooltipComponent {
  constructor() {
    afterNextRender(() => {
      // This only runs in the browser after hydration
      this.initializeFocusMonitoring();
      this.setupKeyboardShortcuts();
    });
  }
}

For SEO and accessibility, ensure that critical content and landmarks are part of the server-rendered HTML:

// In app.config.server.ts
export const config = mergeApplicationConfig(appConfig, {
  providers: [
    provideServerRendering(),
    provideClientHydration()
  ]
});

Common Angular Accessibility Mistakes

Understanding common mistakes helps you avoid them in your own code and catch them during code review. The following patterns appear frequently in Angular applications.

Mistake 1: Using click handlers on non-interactive elements without keyboard support.

<!-- Bad: div is not focusable and has no keyboard interaction -->
<div (click)="selectItem(item)">
  {{ item.name }}
</div>

<!-- Good: use a button or add all required attributes -->
<button (click)="selectItem(item)">
  {{ item.name }}
</button>

<!-- If a button is not possible, add role, tabindex, and keyboard events -->
<div role="button"
     tabindex="0"
     (click)="selectItem(item)"
     (keydown.enter)="selectItem(item)"
     (keydown.space)="selectItem(item); $event.preventDefault()">
  {{ item.name }}
</div>

Mistake 2: Using *ngFor to render interactive lists without ARIA roles.

<!-- Bad: no semantic structure for the list -->
<div *ngFor="let option of options" (click)="select(option)">
  {{ option.label }}
</div>

<!-- Good: proper listbox pattern -->
<ul role="listbox" [attr.aria-label]="listLabel">
  <li role="option"
      *ngFor="let option of options; let i = index"
      [attr.aria-selected]="selectedIndex === i"
      [attr.tabindex]="selectedIndex === i ? 0 : -1"
      (click)="select(option, i)"
      (keydown.enter)="select(option, i)"
      (keydown.space)="select(option, i); $event.preventDefault()">
    {{ option.label }}
  </li>
</ul>

Mistake 3: Destroying and recreating elements that are referenced by ARIA attributes.

<!-- Bad: if the tooltip is controlled by *ngIf, the aria-describedby
     reference becomes invalid when the tooltip is hidden -->
<button [attr.aria-describedby]="'tooltip-' + id">
  Help
</button>
<div *ngIf="showTooltip" [id]="'tooltip-' + id" role="tooltip">
  Helpful information
</div>

<!-- Good: keep the element in the DOM but hide it, or conditionally
     set aria-describedby -->
<button [attr.aria-describedby]="showTooltip ? 'tooltip-' + id : null">
  Help
</button>
<div *ngIf="showTooltip" [id]="'tooltip-' + id" role="tooltip">
  Helpful information
</div>

Mistake 4: Forgetting to unsubscribe from FocusMonitor, leading to memory leaks and stale focus state.

<!-- Bad: no cleanup -->
ngAfterViewInit() {
  this.focusMonitor.monitor(this.el);
}

<!-- Good: clean up on destroy -->
ngAfterViewInit() {
  this.focusMonitor.monitor(this.el);
}
ngOnDestroy() {
  this.focusMonitor.stopMonitoring(this.el);
}

Mistake 5: Using routerLink on non-anchor elements.

<!-- Bad: button with routerLink navigates but loses anchor semantics.
     Screen readers will not announce it as a link. -->
<button routerLink="/about">About Us</button>

<!-- Good: use an anchor element for navigation -->
<a routerLink="/about">About Us</a>

<!-- If you need button styling, style the anchor -->
<a routerLink="/about" class="button-style">About Us</a>

Mistake 6: Binding empty strings instead of null to ARIA attributes.

<!-- Bad: aria-label="" is worse than no aria-label at all.
     It overrides the element's text content with nothing. -->
<button [attr.aria-label]="customLabel || ''">
  Save Changes
</button>

<!-- Good: use null to remove the attribute entirely when
     no custom label is needed -->
<button [attr.aria-label]="customLabel || null">
  Save Changes
</button>

Mistake 7: Not managing focus when dynamically adding or removing content.

// Bad: deleting an item leaves focus in limbo
deleteItem(index: number) {
  this.items.splice(index, 1);
}

// Good: move focus to a logical location after deletion
deleteItem(index: number) {
  this.items.splice(index, 1);

  this.liveAnnouncer.announce('Item deleted', 'assertive');

  setTimeout(() => {
    if (this.items.length === 0) {
      // Focus empty state message
      this.emptyStateRef.nativeElement.focus();
    } else {
      // Focus the next item, or the previous if we deleted the last
      const nextIndex = Math.min(index, this.items.length - 1);
      const nextElement = this.itemElements.toArray()[nextIndex];
      nextElement?.nativeElement.focus();
    }
  });
}

Angular Signals and Accessibility

Angular Signals, introduced in Angular 16 and becoming the primary reactivity model in Angular, change how components track and respond to state changes. While Signals do not directly affect the accessibility tree, they introduce new patterns that developers must handle carefully to maintain accessibility.

The core concern with Signals is that their fine-grained reactivity can update parts of the DOM without triggering component-level change detection. This means screen readers may not always detect content changes. Ensure that dynamic content changes driven by Signals are accompanied by appropriate ARIA live region announcements.

@Component({
  selector: 'app-notification-counter',
  template: `
    <button [attr.aria-label]="buttonLabel()">
      Notifications
      @if (count() > 0) {
        <span class="badge">{{ count() }}</span>
      }
    </button>
    <div aria-live="polite" class="sr-only">
      {{ announcement() }}
    </div>
  `
})
export class NotificationCounterComponent {
  count = signal(0);

  buttonLabel = computed(() => {
    const c = this.count();
    return c === 0
      ? 'Notifications, no new notifications'
      : `Notifications, ${c} new notification${c !== 1 ? 's' : ''}`;
  });

  announcement = signal('');

  incrementCount(by: number) {
    this.count.update(c => c + by);
    this.announcement.set(
      `${by} new notification${by !== 1 ? 's' : ''} received. Total: ${this.count()}.`
    );
  }
}

Signals work well with computed properties for deriving accessible labels. Because computed signals update automatically when their dependencies change, ARIA attributes stay in sync without manual subscription management:

@Component({
  selector: 'app-data-table',
  template: `
    <table [attr.aria-label]="tableLabel()"
           [attr.aria-rowcount]="totalRows()">
      <thead>
        <tr>
          <th *ngFor="let col of columns()"
              [attr.aria-sort]="sortState(col)"
              (click)="sort(col)">
            <button>{{ col.label }}</button>
          </th>
        </tr>
      </thead>
      <tbody>
        <tr *ngFor="let row of visibleRows(); let i = index"
            [attr.aria-rowindex]="(currentPage() - 1) * pageSize() + i + 2">
          <td *ngFor="let col of columns()">{{ row[col.key] }}</td>
        </tr>
      </tbody>
    </table>
    <div aria-live="polite" class="sr-only">{{ tableAnnouncement() }}</div>
  `
})
export class DataTableComponent {
  data = signal<any[]>([]);
  columns = signal<Column[]>([]);
  currentPage = signal(1);
  pageSize = signal(20);
  sortColumn = signal<string | null>(null);
  sortDirection = signal<'ascending' | 'descending'>('ascending');
  tableAnnouncement = signal('');

  totalRows = computed(() => this.data().length);

  visibleRows = computed(() => {
    const start = (this.currentPage() - 1) * this.pageSize();
    return this.data().slice(start, start + this.pageSize());
  });

  tableLabel = computed(() =>
    `Data table, page ${this.currentPage()} of ${Math.ceil(this.totalRows() / this.pageSize())}, ${this.totalRows()} total rows`
  );

  sortState(col: Column): string | null {
    if (this.sortColumn() !== col.key) return null;
    return this.sortDirection();
  }

  sort(col: Column) {
    if (this.sortColumn() === col.key) {
      this.sortDirection.update(d =>
        d === 'ascending' ? 'descending' : 'ascending'
      );
    } else {
      this.sortColumn.set(col.key);
      this.sortDirection.set('ascending');
    }
    this.tableAnnouncement.set(
      `Table sorted by ${col.label}, ${this.sortDirection()} order.`
    );
  }
}

When using the effect() function to react to Signal changes, be cautious about triggering multiple announcements. Effects run synchronously when signals change, and rapid consecutive changes can produce a flood of screen reader announcements:

// Bad: announces every intermediate state during rapid updates
effect(() => {
  this.liveAnnouncer.announce(`Count is now ${this.count()}`);
});

// Good: debounce announcements for rapidly changing values
private announcementSubject = new Subject<string>();

constructor(private liveAnnouncer: LiveAnnouncer) {
  this.announcementSubject.pipe(
    debounceTime(300)
  ).subscribe(message => {
    this.liveAnnouncer.announce(message, 'polite');
  });

  effect(() => {
    this.announcementSubject.next(`Count is now ${this.count()}`);
  });
}

The model() signal, introduced for two-way binding in Angular 17.1, is particularly relevant for accessible form components. It provides a clean way to create custom form controls that work with Angular's forms system:

@Component({
  selector: 'app-star-rating',
  template: `
    <div role="radiogroup"
         [attr.aria-label]="label()"
         (keydown)="onKeydown($event)">
      @for (star of stars; track star; let i = $index) {
        <button role="radio"
                [attr.aria-checked]="i < value()"
                [attr.aria-label]="(i + 1) + ' star' + (i !== 0 ? 's' : '')"
                [attr.tabindex]="i === value() - 1 || (value() === 0 && i === 0) ? 0 : -1"
                (click)="setValue(i + 1)">
          {{ i < value() ? '★' : '☆' }}
        </button>
      }
    </div>
    <div aria-live="polite" class="sr-only">{{ announcement() }}</div>
  `
})
export class StarRatingComponent {
  value = model(0);
  label = input('Rating');
  stars = [1, 2, 3, 4, 5];
  announcement = signal('');

  setValue(rating: number) {
    this.value.set(rating);
    this.announcement.set(
      `Rating set to ${rating} out of ${this.stars.length} stars.`
    );
  }

  onKeydown(event: KeyboardEvent) {
    if (event.key === 'ArrowRight' || event.key === 'ArrowUp') {
      event.preventDefault();
      this.setValue(Math.min(this.value() + 1, this.stars.length));
    } else if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') {
      event.preventDefault();
      this.setValue(Math.max(this.value() - 1, 1));
    }
  }
}

Putting It All Together: An Accessibility Checklist for Angular Projects

Building accessible Angular applications requires attention at every layer of the framework. The following checklist summarizes the key practices covered in this guide:

  • Import and use the CDK A11yModule for focus trapping, focus monitoring, and live announcements in every feature module that needs them.
  • Use FocusTrap on all modal dialogs, drawers, and overlays. Ensure focus is captured on open and restored on close.
  • Use LiveAnnouncer or aria-live regions to communicate dynamic changes — search results, form submissions, data updates, and navigation events.
  • Bind ARIA attributes with [attr.aria-*] and use null (not empty string) to remove attributes when they are not applicable.
  • Associate form validation errors with their controls using aria-describedby. Use aria-invalid to mark invalid fields. Announce error summaries on form submission.
  • Implement a custom TitleStrategy that announces route changes and set meaningful page titles on every route.
  • Move focus to the main heading or content area after route navigation.
  • Use @Input({ required: true }) for mandatory ARIA attributes on reusable components like icon buttons.
  • Create reusable directives for common patterns: auto-focus, roving tabindex, live regions.
  • Guard DOM access in SSR-compatible code with isPlatformBrowser or afterNextRender.
  • Debounce announcements for rapidly changing Signal values to prevent screen reader flooding.
  • Integrate axe-core into both unit tests (via jasmine-axe or jest-axe) and end-to-end tests (via @axe-core/playwright).
  • Always use native HTML elements (<button>, <a>, <input>) over custom elements with ARIA roles. Only use ARIA when native semantics are insufficient.
  • Clean up FocusMonitor subscriptions in ngOnDestroy to prevent memory leaks.
  • Manage focus explicitly when adding or removing content — never leave the user's focus position undefined.

Είναι προσβάσιμος ο ιστότοπός σας;

Σαρώστε τον ιστότοπό σας δωρεάν και λάβετε τη βαθμολογία WCAG σας σε λίγα λεπτά.

Σαρώστε τον ιστότοπό σας δωρεάν