Accessibility Overview
Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: Components, Forms
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ 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
Copy
npm install @angular/cdk
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ 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