Skip to main content
Angular Accessibility

Accessibility Overview

Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: Components, Forms
Accessibility (a11y) ensures your application is usable by people with disabilities, including those using screen readers, keyboard navigation, or other assistive technologies. It’s not just ethical—it’s often a legal requirement.
┌─────────────────────────────────────────────────────────────────────────┐
│                    Web Accessibility Pillars (WCAG)                      │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   PERCEIVABLE              OPERABLE                UNDERSTANDABLE       │
│   ─────────────           ─────────               ───────────────       │
│   • Text alternatives     • Keyboard access       • Readable text       │
│   • Captions/transcripts  • Enough time           • Predictable UI      │
│   • Adaptable content     • Seizure prevention    • Input assistance    │
│   • Distinguishable       • Navigable             • Error handling      │
│                                                                          │
│   ROBUST                                                                 │
│   ───────                                                                │
│   • Compatible with assistive technologies                               │
│   • Valid, semantic HTML                                                 │
│   • ARIA when needed                                                    │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Angular CDK A11y Module

npm install @angular/cdk
// app.config.ts
import { A11yModule } from '@angular/cdk/a11y';

// Or import specific modules as needed
import { 
  FocusMonitor,
  LiveAnnouncer,
  FocusTrap,
  InteractivityChecker 
} from '@angular/cdk/a11y';

Focus Management

Focus Trap

// modal.component.ts
import { CdkTrapFocus } from '@angular/cdk/a11y';

@Component({
  selector: 'app-modal',
  standalone: true,
  imports: [CdkTrapFocus],
  template: `
    <div 
      class="modal-backdrop" 
      (click)="close()"
      (keydown.escape)="close()"
    >
      <div 
        class="modal-content" 
        cdkTrapFocus
        [cdkTrapFocusAutoCapture]="true"
        role="dialog"
        aria-modal="true"
        [attr.aria-labelledby]="titleId"
        [attr.aria-describedby]="descriptionId"
        (click)="$event.stopPropagation()"
      >
        <h2 [id]="titleId">{{ title() }}</h2>
        <p [id]="descriptionId">{{ description() }}</p>
        
        <ng-content />
        
        <div class="modal-actions">
          <button (click)="close()">Cancel</button>
          <button (click)="confirm()">Confirm</button>
        </div>
      </div>
    </div>
  `
})
export class ModalComponent implements OnInit, OnDestroy {
  title = input.required<string>();
  description = input<string>('');
  
  closed = output<void>();
  confirmed = output<void>();
  
  titleId = `modal-title-${uniqueId++}`;
  descriptionId = `modal-desc-${uniqueId++}`;
  
  private previouslyFocused: HTMLElement | null = null;
  
  ngOnInit() {
    // Store currently focused element
    this.previouslyFocused = document.activeElement as HTMLElement;
    // Prevent body scroll
    document.body.style.overflow = 'hidden';
  }
  
  ngOnDestroy() {
    // Restore focus and scroll
    this.previouslyFocused?.focus();
    document.body.style.overflow = '';
  }
  
  close() {
    this.closed.emit();
  }
  
  confirm() {
    this.confirmed.emit();
  }
}

let uniqueId = 0;

Focus Monitor

// button.component.ts
import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';

@Component({
  selector: 'app-button',
  standalone: true,
  template: `
    <button 
      #btn
      [class.focus-visible]="focusVisible()"
      [class.focus-keyboard]="focusOrigin() === 'keyboard'"
      [class.focus-mouse]="focusOrigin() === 'mouse'"
    >
      <ng-content />
    </button>
  `,
  styles: [`
    button:focus {
      outline: none;
    }
    button.focus-keyboard {
      outline: 2px solid var(--focus-ring-color);
      outline-offset: 2px;
    }
  `]
})
export class ButtonComponent implements AfterViewInit, OnDestroy {
  private focusMonitor = inject(FocusMonitor);
  @ViewChild('btn') button!: ElementRef<HTMLButtonElement>;
  
  focusVisible = signal(false);
  focusOrigin = signal<FocusOrigin>(null);
  
  ngAfterViewInit() {
    this.focusMonitor.monitor(this.button.nativeElement).subscribe(origin => {
      this.focusOrigin.set(origin);
      this.focusVisible.set(origin !== null);
    });
  }
  
  ngOnDestroy() {
    this.focusMonitor.stopMonitoring(this.button.nativeElement);
  }
}

Programmatic Focus

// form.component.ts
@Component({
  template: `
    <form (ngSubmit)="onSubmit()">
      <div class="field">
        <label for="name">Name</label>
        <input #nameInput id="name" [(ngModel)]="name" required />
      </div>
      
      <div class="field">
        <label for="email">Email</label>
        <input #emailInput id="email" [(ngModel)]="email" type="email" required />
      </div>
      
      <button type="submit">Submit</button>
      
      @if (errors().length > 0) {
        <div 
          #errorSummary
          class="error-summary"
          role="alert"
          tabindex="-1"
        >
          <h3>Please fix the following errors:</h3>
          <ul>
            @for (error of errors(); track error.field) {
              <li>
                <a [href]="'#' + error.field" (click)="focusField($event, error.field)">
                  {{ error.message }}
                </a>
              </li>
            }
          </ul>
        </div>
      }
    </form>
  `
})
export class FormComponent {
  @ViewChild('nameInput') nameInput!: ElementRef;
  @ViewChild('emailInput') emailInput!: ElementRef;
  @ViewChild('errorSummary') errorSummary!: ElementRef;
  
  name = '';
  email = '';
  errors = signal<{ field: string; message: string }[]>([]);
  
  private fieldMap: Record<string, ElementRef> = {};
  
  ngAfterViewInit() {
    this.fieldMap = {
      name: this.nameInput,
      email: this.emailInput
    };
  }
  
  onSubmit() {
    const errors: { field: string; message: string }[] = [];
    
    if (!this.name) {
      errors.push({ field: 'name', message: 'Name is required' });
    }
    if (!this.email || !this.email.includes('@')) {
      errors.push({ field: 'email', message: 'Valid email is required' });
    }
    
    this.errors.set(errors);
    
    if (errors.length > 0) {
      // Focus error summary for screen readers
      setTimeout(() => {
        this.errorSummary.nativeElement.focus();
      });
    }
  }
  
  focusField(event: Event, fieldName: string) {
    event.preventDefault();
    this.fieldMap[fieldName]?.nativeElement.focus();
  }
}

Live Announcements

// notification.service.ts
import { LiveAnnouncer } from '@angular/cdk/a11y';

@Injectable({ providedIn: 'root' })
export class NotificationService {
  private announcer = inject(LiveAnnouncer);
  
  announce(message: string, politeness: 'polite' | 'assertive' = 'polite') {
    this.announcer.announce(message, politeness);
  }
  
  // Common announcements
  success(action: string) {
    this.announce(`${action} successful`, 'polite');
  }
  
  error(message: string) {
    this.announce(`Error: ${message}`, 'assertive');
  }
  
  loading(action: string) {
    this.announce(`${action} in progress`, 'polite');
  }
  
  loaded(count: number, item: string) {
    const message = count === 1 
      ? `1 ${item} loaded`
      : `${count} ${item}s loaded`;
    this.announce(message, 'polite');
  }
}

// Usage in component
@Component({...})
export class ProductListComponent {
  private notify = inject(NotificationService);
  
  async loadProducts() {
    this.notify.loading('Loading products');
    
    try {
      const products = await this.api.getProducts();
      this.products.set(products);
      this.notify.loaded(products.length, 'product');
    } catch (error) {
      this.notify.error('Failed to load products');
    }
  }
  
  deleteProduct(id: string) {
    this.api.deleteProduct(id).subscribe({
      next: () => this.notify.success('Product deleted'),
      error: () => this.notify.error('Failed to delete product')
    });
  }
}

Semantic HTML & ARIA

Accessible Navigation

// navigation.component.ts
@Component({
  selector: 'app-navigation',
  template: `
    <nav aria-label="Main navigation">
      <ul role="menubar">
        @for (item of menuItems(); track item.path) {
          @if (item.children) {
            <li role="none">
              <button
                role="menuitem"
                aria-haspopup="true"
                [attr.aria-expanded]="isExpanded(item.path)"
                (click)="toggleMenu(item.path)"
                (keydown)="onKeydown($event, item)"
              >
                {{ item.label }}
                <span class="icon" aria-hidden="true">▼</span>
              </button>
              
              @if (isExpanded(item.path)) {
                <ul role="menu" [attr.aria-label]="item.label + ' submenu'">
                  @for (child of item.children; track child.path) {
                    <li role="none">
                      <a 
                        role="menuitem"
                        [routerLink]="child.path"
                        (keydown)="onSubmenuKeydown($event, child)"
                      >
                        {{ child.label }}
                      </a>
                    </li>
                  }
                </ul>
              }
            </li>
          } @else {
            <li role="none">
              <a 
                role="menuitem" 
                [routerLink]="item.path"
                routerLinkActive="active"
                [attr.aria-current]="isActive(item.path) ? 'page' : null"
              >
                {{ item.label }}
              </a>
            </li>
          }
        }
      </ul>
    </nav>
    
    <!-- Skip link for keyboard users -->
    <a href="#main-content" class="skip-link">
      Skip to main content
    </a>
  `,
  styles: [`
    .skip-link {
      position: absolute;
      left: -9999px;
      z-index: 999;
      padding: 1em;
      background: var(--primary-color);
      color: white;
    }
    .skip-link:focus {
      left: 50%;
      transform: translateX(-50%);
    }
  `]
})
export class NavigationComponent {
  menuItems = input.required<MenuItem[]>();
  expandedMenus = signal<Set<string>>(new Set());
  
  isExpanded(path: string): boolean {
    return this.expandedMenus().has(path);
  }
  
  toggleMenu(path: string) {
    this.expandedMenus.update(menus => {
      const newMenus = new Set(menus);
      if (newMenus.has(path)) {
        newMenus.delete(path);
      } else {
        newMenus.add(path);
      }
      return newMenus;
    });
  }
  
  onKeydown(event: KeyboardEvent, item: MenuItem) {
    switch (event.key) {
      case 'Enter':
      case ' ':
        event.preventDefault();
        this.toggleMenu(item.path);
        break;
      case 'ArrowDown':
        event.preventDefault();
        if (!this.isExpanded(item.path)) {
          this.toggleMenu(item.path);
        }
        // Focus first submenu item
        break;
      case 'Escape':
        if (this.isExpanded(item.path)) {
          this.toggleMenu(item.path);
        }
        break;
    }
  }
}

Accessible Forms

// accessible-input.component.ts
@Component({
  selector: 'app-input',
  template: `
    <div class="form-field" [class.has-error]="hasError()">
      <label [for]="id" [class.required]="required()">
        {{ label() }}
        @if (required()) {
          <span class="visually-hidden">(required)</span>
        }
      </label>
      
      <div class="input-wrapper">
        <input
          [id]="id"
          [type]="type()"
          [attr.aria-describedby]="describedBy"
          [attr.aria-invalid]="hasError()"
          [attr.aria-required]="required()"
          [(ngModel)]="value"
          (blur)="touched.set(true)"
        />
        
        @if (hasError()) {
          <span class="error-icon" aria-hidden="true">⚠</span>
        }
      </div>
      
      @if (hint()) {
        <p [id]="hintId" class="hint">{{ hint() }}</p>
      }
      
      @if (hasError()) {
        <p [id]="errorId" class="error" role="alert">
          {{ errorMessage() }}
        </p>
      }
    </div>
  `,
  styles: [`
    .visually-hidden {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      border: 0;
    }
    
    .required::after {
      content: '*';
      color: var(--error-color);
      margin-left: 0.25em;
    }
    
    .has-error input {
      border-color: var(--error-color);
    }
    
    .error {
      color: var(--error-color);
      font-size: 0.875rem;
    }
  `]
})
export class AccessibleInputComponent {
  private static counter = 0;
  
  label = input.required<string>();
  type = input<string>('text');
  hint = input<string>('');
  required = input(false);
  errorMessage = input<string>('');
  
  value = model<string>('');
  touched = signal(false);
  
  id = `input-${AccessibleInputComponent.counter++}`;
  hintId = `${this.id}-hint`;
  errorId = `${this.id}-error`;
  
  get describedBy(): string {
    const ids: string[] = [];
    if (this.hint()) ids.push(this.hintId);
    if (this.hasError()) ids.push(this.errorId);
    return ids.join(' ') || null;
  }
  
  hasError(): boolean {
    return this.touched() && !!this.errorMessage();
  }
}

Accessible Data Tables

// data-table.component.ts
@Component({
  selector: 'app-data-table',
  template: `
    <div class="table-container" role="region" [attr.aria-label]="caption()">
      <table>
        <caption class="visually-hidden">{{ caption() }}</caption>
        
        <thead>
          <tr>
            @for (column of columns(); track column.key) {
              <th 
                [attr.scope]="'col'"
                [attr.aria-sort]="getSortDirection(column.key)"
                [class.sortable]="column.sortable"
              >
                @if (column.sortable) {
                  <button 
                    (click)="sort(column.key)"
                    [attr.aria-label]="'Sort by ' + column.label"
                  >
                    {{ column.label }}
                    <span class="sort-icon" aria-hidden="true">
                      {{ getSortIcon(column.key) }}
                    </span>
                  </button>
                } @else {
                  {{ column.label }}
                }
              </th>
            }
            <th scope="col">
              <span class="visually-hidden">Actions</span>
            </th>
          </tr>
        </thead>
        
        <tbody>
          @for (row of data(); track row.id; let i = $index) {
            <tr [attr.aria-rowindex]="i + 1">
              @for (column of columns(); track column.key) {
                <td [attr.data-label]="column.label">
                  {{ row[column.key] }}
                </td>
              }
              <td>
                <button 
                  [attr.aria-label]="'Edit ' + row.name"
                  (click)="edit(row)"
                >
                  Edit
                </button>
                <button 
                  [attr.aria-label]="'Delete ' + row.name"
                  (click)="delete(row)"
                >
                  Delete
                </button>
              </td>
            </tr>
          } @empty {
            <tr>
              <td [attr.colspan]="columns().length + 1">
                No data available
              </td>
            </tr>
          }
        </tbody>
      </table>
      
      <!-- Pagination -->
      <nav aria-label="Table pagination">
        <button 
          [disabled]="currentPage() === 1"
          [attr.aria-label]="'Go to previous page'"
          (click)="prevPage()"
        >
          Previous
        </button>
        
        <span aria-live="polite">
          Page {{ currentPage() }} of {{ totalPages() }}
        </span>
        
        <button 
          [disabled]="currentPage() === totalPages()"
          [attr.aria-label]="'Go to next page'"
          (click)="nextPage()"
        >
          Next
        </button>
      </nav>
    </div>
  `
})
export class DataTableComponent<T extends { id: string; name: string }> {
  caption = input.required<string>();
  columns = input.required<TableColumn[]>();
  data = input.required<T[]>();
  
  sortColumn = signal<string | null>(null);
  sortDirection = signal<'ascending' | 'descending' | 'none'>('none');
  currentPage = signal(1);
  totalPages = signal(1);
  
  private announcer = inject(LiveAnnouncer);
  
  getSortDirection(column: string): 'ascending' | 'descending' | 'none' {
    return this.sortColumn() === column ? this.sortDirection() : 'none';
  }
  
  getSortIcon(column: string): string {
    if (this.sortColumn() !== column) return '↕';
    return this.sortDirection() === 'ascending' ? '↑' : '↓';
  }
  
  sort(column: string) {
    if (this.sortColumn() === column) {
      this.sortDirection.update(dir => 
        dir === 'ascending' ? 'descending' : 'ascending'
      );
    } else {
      this.sortColumn.set(column);
      this.sortDirection.set('ascending');
    }
    
    this.announcer.announce(
      `Sorted by ${column}, ${this.sortDirection()}`
    );
  }
}

Testing Accessibility

Using axe-core

// accessibility.spec.ts
import { ComponentFixture, TestBed } from '@angular/core/testing';
import axe from 'axe-core';

describe('Accessibility Tests', () => {
  let fixture: ComponentFixture<AppComponent>;
  
  beforeEach(async () => {
    await TestBed.configureTestingModule({
      imports: [AppComponent]
    }).compileComponents();
    
    fixture = TestBed.createComponent(AppComponent);
    fixture.detectChanges();
  });
  
  it('should have no accessibility violations', async () => {
    const results = await axe.run(fixture.nativeElement);
    expect(results.violations).toEqual([]);
  });
  
  it('should have proper heading hierarchy', () => {
    const headings = fixture.nativeElement.querySelectorAll('h1, h2, h3, h4, h5, h6');
    const levels = Array.from(headings).map((h: Element) => 
      parseInt(h.tagName.substring(1))
    );
    
    for (let i = 1; i < levels.length; i++) {
      // Each heading should not skip more than one level
      expect(levels[i] - levels[i - 1]).toBeLessThanOrEqual(1);
    }
  });
  
  it('should have alt text on all images', () => {
    const images = fixture.nativeElement.querySelectorAll('img');
    images.forEach((img: HTMLImageElement) => {
      expect(img.hasAttribute('alt')).toBeTrue();
    });
  });
  
  it('should have labels for all form controls', () => {
    const inputs = fixture.nativeElement.querySelectorAll(
      'input, select, textarea'
    );
    
    inputs.forEach((input: HTMLInputElement) => {
      const hasLabel = input.id && 
        fixture.nativeElement.querySelector(`label[for="${input.id}"]`);
      const hasAriaLabel = input.hasAttribute('aria-label');
      const hasAriaLabelledBy = input.hasAttribute('aria-labelledby');
      
      expect(hasLabel || hasAriaLabel || hasAriaLabelledBy).toBeTrue();
    });
  });
});

Best Practices Checklist

Keyboard Navigation

All interactive elements accessible via keyboard with visible focus

Screen Reader Support

Proper ARIA labels, live regions, and semantic HTML

Color Contrast

Minimum 4.5:1 ratio for normal text, 3:1 for large text

Focus Management

Logical focus order, focus trapping in modals
┌─────────────────────────────────────────────────────────────────────────┐
│              Accessibility Checklist                                     │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   □ All images have meaningful alt text                                 │
│   □ Form inputs have associated labels                                   │
│   □ Error messages are announced to screen readers                      │
│   □ Focus is managed in modals and dialogs                               │
│   □ Skip links are provided for navigation                               │
│   □ Color is not the only way to convey information                     │
│   □ Text can be resized up to 200% without loss of content              │
│   □ Interactive elements have minimum 44x44px touch target              │
│   □ Animations respect prefers-reduced-motion                            │
│   □ Page has proper heading hierarchy                                    │
│   □ Links have descriptive text (not "click here")                      │
│   □ ARIA is used correctly and only when needed                          │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Next: Angular Material & CDK

Build beautiful, accessible UIs with Angular Material