Skip to main content

Web Accessibility

This guide focuses on practical accessibility implementation in frontend applications using React and Angular. For comprehensive coverage of accessibility principles, WCAG compliance, and cross-platform guidelines, see the Accessibility Guidelines.

Quick Reference

Frontend accessibility requires attention to:

  • Semantic HTML: Use native elements (<button>, <a>, <input>) instead of divs with event handlers
  • ARIA Attributes: Enhance semantics when HTML alone is insufficient (role, aria-label, aria-live)
  • Keyboard Navigation: All functionality must work without a mouse (Tab, Enter, Space, Escape, Arrows)
  • Focus Management: Visible focus indicators and proper focus handling in SPAs
  • Form Labels: Every input needs an associated <label> element
  • Color Contrast: Text must meet 4.5:1 (normal) or 3:1 (large) contrast ratios
  • Screen Readers: Test with NVDA (Windows), VoiceOver (macOS/iOS), or TalkBack (Android)
  • Automated Testing: Integrate axe-core into unit tests and CI pipelines

For detailed explanations of these concepts, WCAG conformance levels, and comprehensive testing strategies, see Accessibility Guidelines.


React Accessibility Patterns

Semantic Components

React components should use semantic HTML as their foundation:

//  BAD: Divs with onClick handlers
export function PaymentCard({ payment, onSelect }: PaymentCardProps) {
return (
<div className="payment-card" onClick={() => onSelect(payment)}>
<div className="amount">${payment.amount}</div>
<div className="recipient">{payment.recipient}</div>
</div>
);
}

// GOOD: Button for interactive elements
export function PaymentCard({ payment, onSelect }: PaymentCardProps) {
return (
<button
type="button"
className="payment-card"
onClick={() => onSelect(payment)}
>
<div className="amount">${payment.amount}</div>
<div className="recipient">{payment.recipient}</div>
</button>
);
}

Using <button> provides keyboard accessibility (Enter/Space activation), proper focus management, and correct screen reader announcements without additional ARIA.

Focus Management in SPAs

Single-page applications must manage focus when content changes without full page reloads. Moving focus to new content helps keyboard and screen reader users understand what changed.

import { useEffect, useRef } from 'react';

export function AccountDetailsPage({ accountId }: Props) {
const headingRef = useRef<HTMLHeadingElement>(null);

// Focus heading when page content loads
useEffect(() => {
headingRef.current?.focus();
}, [accountId]);

return (
<main>
<h1 ref={headingRef} tabIndex={-1}>
Account Details
</h1>
{/* Account content */}
</main>
);
}

The tabIndex={-1} allows programmatic focus (via focus()) but removes the heading from the Tab order, preventing confusion for keyboard users.

When to Move Focus:

  • After route changes in SPAs, focus the main heading or skip link target
  • When opening modals, focus the modal container or first focusable element
  • After deleting items, focus the next item or a status message
  • After form submission, focus the success message or error summary

See Accessibility Guidelines - Focus Management for modal focus trapping patterns.

ARIA Live Regions for Dynamic Updates

Use ARIA live regions to announce dynamic content changes to screen readers without moving focus:

import { useState } from 'react';

export function TransferForm() {
const [status, setStatus] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);

const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setStatus('Processing transfer...');

try {
await submitTransfer();
setStatus('Transfer completed successfully');
} catch (error) {
setStatus('Transfer failed. Please try again.');
} finally {
setIsSubmitting(false);
}
};

return (
<form onSubmit={handleSubmit}>
{/* Form fields */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Processing...' : 'Submit Transfer'}
</button>

{/* Screen reader live region */}
<div
role="status"
aria-live="polite"
aria-atomic="true"
className="sr-only" // Visually hidden CSS class
>
{status}
</div>

{/* Visual status message */}
{status && (
<div className={status.includes('failed') ? 'error' : 'success'}>
{status}
</div>
)}
</form>
);
}

Live Region Politeness Levels:

  • aria-live="polite": Announces when screen reader is idle (most common)
  • aria-live="assertive": Interrupts current announcement (use sparingly for urgent alerts)
  • role="status": Equivalent to aria-live="polite" (preferred for status messages)
  • role="alert": Equivalent to aria-live="assertive" (for errors and warnings)

The aria-atomic="true" attribute ensures the entire region is announced when updated, not just the changed portion.

Accessible Form Validation

Connect error messages to form inputs using aria-describedby and aria-invalid:

import { useState } from 'react';

export function AccountNumberInput() {
const [value, setValue] = useState('');
const [error, setError] = useState('');

const validate = (input: string) => {
if (!/^\d{10}$/.test(input)) {
setError('Account number must be exactly 10 digits');
} else {
setError('');
}
};

return (
<div className="form-group">
<label htmlFor="account-number">
Account Number
<span aria-label="required" className="required">*</span>
</label>

<input
id="account-number"
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
validate(e.target.value);
}}
aria-required="true"
aria-invalid={!!error}
aria-describedby={error ? 'account-number-error' : undefined}
className={error ? 'input-error' : ''}
/>

{error && (
<div
id="account-number-error"
className="error-message"
role="alert" // Announces immediately when error appears
>
{error}
</div>
)}
</div>
);
}

Key Attributes:

  • aria-required="true": Indicates required field (redundant with required attribute but helpful for screen readers)
  • aria-invalid="true": Marks field as containing invalid value
  • aria-describedby: References error message element by ID
  • role="alert": Announces error immediately when it appears

For comprehensive form accessibility patterns including error summaries and fieldsets, see Accessibility Guidelines - Accessible Forms.

Custom Component Accessibility

When building custom interactive components, ensure they have proper keyboard support and ARIA attributes:

import { useState, useRef, useEffect } from 'react';

interface DropdownProps {
label: string;
options: Array<{ value: string; label: string }>;
onChange: (value: string) => void;
}

export function CustomDropdown({ label, options, onChange }: DropdownProps) {
const [isOpen, setIsOpen] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const buttonRef = useRef<HTMLButtonElement>(null);
const listboxRef = useRef<HTMLUListElement>(null);

const handleKeyDown = (e: React.KeyboardEvent) => {
switch (e.key) {
case 'Enter':
case ' ':
if (!isOpen) {
e.preventDefault();
setIsOpen(true);
} else {
e.preventDefault();
selectOption(highlightedIndex);
}
break;

case 'Escape':
setIsOpen(false);
buttonRef.current?.focus();
break;

case 'ArrowDown':
e.preventDefault();
if (!isOpen) {
setIsOpen(true);
} else {
setHighlightedIndex((prev) =>
prev < options.length - 1 ? prev + 1 : prev
);
}
break;

case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((prev) => (prev > 0 ? prev - 1 : prev));
break;

case 'Home':
e.preventDefault();
setHighlightedIndex(0);
break;

case 'End':
e.preventDefault();
setHighlightedIndex(options.length - 1);
break;
}
};

const selectOption = (index: number) => {
setSelectedIndex(index);
onChange(options[index].value);
setIsOpen(false);
buttonRef.current?.focus();
};

return (
<div className="custom-dropdown">
<label id="dropdown-label">{label}</label>

<button
ref={buttonRef}
type="button"
aria-haspopup="listbox"
aria-expanded={isOpen}
aria-labelledby="dropdown-label"
onClick={() => setIsOpen(!isOpen)}
onKeyDown={handleKeyDown}
className="dropdown-button"
>
{selectedIndex >= 0 ? options[selectedIndex].label : 'Select...'}
</button>

{isOpen && (
<ul
ref={listboxRef}
role="listbox"
aria-labelledby="dropdown-label"
aria-activedescendant={`option-${highlightedIndex}`}
onKeyDown={handleKeyDown}
className="dropdown-list"
>
{options.map((option, index) => (
<li
key={option.value}
id={`option-${index}`}
role="option"
aria-selected={index === selectedIndex}
className={index === highlightedIndex ? 'highlighted' : ''}
onClick={() => selectOption(index)}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}

Implementation Notes:

  • Button triggers dropdown with aria-haspopup="listbox" and aria-expanded state
  • Arrow keys navigate options without selecting
  • Enter/Space selects the highlighted option
  • Escape closes dropdown and returns focus to trigger button
  • aria-activedescendant indicates which option is highlighted while focus remains on the container

For complete ARIA patterns including comboboxes, tabs, and accordions, see ARIA Authoring Practices Guide and Accessibility Guidelines - ARIA.


Angular Accessibility Patterns

Angular provides built-in accessibility support through the CDK (Component Dev Kit):

npm install @angular/cdk

CDK Focus Management

import { Component, ElementRef, OnInit } from '@angular/core';
import { FocusTrap, FocusTrapFactory } from '@angular/cdk/a11y';

@Component({
selector: 'app-modal',
template: `
<div class="modal-backdrop" *ngIf="isOpen" (click)="close()">
<div #modalContent class="modal-content" (click)="$event.stopPropagation()">
<h2 id="modal-title">{{ title }}</h2>
<ng-content></ng-content>
<button (click)="close()">Close</button>
</div>
</div>
`
})
export class ModalComponent implements OnInit {
@Input() isOpen = false;
@Input() title = '';
@Output() closed = new EventEmitter<void>();

@ViewChild('modalContent') modalContent!: ElementRef<HTMLElement>;

private focusTrap?: FocusTrap;
private previouslyFocusedElement?: HTMLElement;

constructor(private focusTrapFactory: FocusTrapFactory) {}

ngOnInit() {
if (this.isOpen) {
this.openModal();
}
}

ngOnChanges(changes: SimpleChanges) {
if (changes['isOpen']) {
if (this.isOpen) {
this.openModal();
} else {
this.closeModal();
}
}
}

private openModal() {
// Store current focus
this.previouslyFocusedElement = document.activeElement as HTMLElement;

// Create focus trap
this.focusTrap = this.focusTrapFactory.create(
this.modalContent.nativeElement
);

// Focus first element in modal
this.focusTrap.focusInitialElement();
}

private closeModal() {
// Destroy focus trap
this.focusTrap?.destroy();

// Restore previous focus
this.previouslyFocusedElement?.focus();
}

close() {
this.closed.emit();
}

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

The Angular CDK's FocusTrap automatically handles focus trapping, removing the need for manual Tab key interception.

CDK Live Announcer

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

@Component({
selector: 'app-payment-form',
template: `
<form (ngSubmit)="submitPayment()">
<input [(ngModel)]="amount" name="amount" />
<button type="submit">Submit Payment</button>
</form>
`
})
export class PaymentFormComponent {
amount = '';

constructor(private liveAnnouncer: LiveAnnouncer) {}

async submitPayment() {
try {
await this.paymentService.submit(this.amount);

// Announce success to screen readers
this.liveAnnouncer.announce(
'Payment submitted successfully',
'polite' // or 'assertive' for urgent announcements
);
} catch (error) {
this.liveAnnouncer.announce(
'Payment failed. Please try again.',
'assertive'
);
}
}
}

The LiveAnnouncer creates a live region automatically and manages announcements without requiring manual ARIA attributes.

Angular Forms Accessibility

Angular's reactive forms integrate with accessibility attributes:

import { Component } from '@angular/core';
import { FormBuilder, Validators } from '@angular/forms';

@Component({
selector: 'app-transfer-form',
template: `
<form [formGroup]="transferForm" (ngSubmit)="onSubmit()">
<div class="form-group">
<label for="account-number">
Account Number
<span aria-label="required" class="required">*</span>
</label>

<input
id="account-number"
formControlName="accountNumber"
type="text"
[attr.aria-invalid]="accountNumber?.invalid && accountNumber?.touched"
[attr.aria-describedby]="
accountNumber?.invalid && accountNumber?.touched
? 'account-number-error'
: null
"
/>

<div
*ngIf="accountNumber?.invalid && accountNumber?.touched"
id="account-number-error"
class="error-message"
role="alert"
>
<span *ngIf="accountNumber?.errors?.['required']">
Account number is required
</span>
<span *ngIf="accountNumber?.errors?.['pattern']">
Account number must be 10 digits
</span>
</div>
</div>

<button type="submit" [disabled]="transferForm.invalid">
Submit Transfer
</button>
</form>
`
})
export class TransferFormComponent {
transferForm = this.fb.group({
accountNumber: ['', [Validators.required, Validators.pattern(/^\d{10}$/)]],
});

get accountNumber() {
return this.transferForm.get('accountNumber');
}

constructor(private fb: FormBuilder) {}

onSubmit() {
if (this.transferForm.valid) {
// Submit form
}
}
}

Angular Accessibility Best Practices:

  • Use [attr.aria-*] for dynamic ARIA attributes in templates
  • Leverage Angular CDK for focus management and live announcements
  • Bind aria-invalid to form control validity state
  • Display errors only after user interaction (touched or dirty)
  • Use role="alert" for immediate error announcements

For more Angular patterns, see Angular General Guidelines.


Automated Accessibility Testing

ESLint Integration

Install the JSX accessibility linting plugin:

npm install --save-dev eslint-plugin-jsx-a11y

Configure ESLint:

// .eslintrc.json
{
"extends": [
"plugin:jsx-a11y/recommended"
],
"plugins": ["jsx-a11y"],
"rules": {
"jsx-a11y/anchor-is-valid": "error",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/label-has-associated-control": "error",
"jsx-a11y/img-redundant-alt": "error",
"jsx-a11y/no-autofocus": "warn"
}
}

This catches common accessibility mistakes during development, such as images without alt text, buttons without keyboard support, or forms without labels.

Jest + jest-axe

Integrate axe-core into unit tests to catch accessibility violations:

npm install --save-dev jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import { PaymentForm } from './PaymentForm';

// Extend Jest matchers
expect.extend(toHaveNoViolations);

describe('PaymentForm Accessibility', () => {
test('should have no accessibility violations', async () => {
const { container } = render(<PaymentForm />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('should have no violations in error state', async () => {
const { container } = render(<PaymentForm initialErrors={{ amount: 'Invalid amount' }} />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

test('should have no violations with modal open', async () => {
const { container, getByRole } = render(<PaymentForm />);

// Open modal
const openModalButton = getByRole('button', { name: /confirm payment/i });
openModalButton.click();

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

jest-axe Best Practices:

  • Test multiple component states (initial, loading, error, success)
  • Test interactive states (modals open, dropdowns expanded, forms with errors)
  • Configure axe to ignore known false positives:
const results = await axe(container, {
rules: {
// Disable specific rules if needed
'color-contrast': { enabled: false } // Example: if testing against dark theme
}
});

React Testing Library Accessible Queries

React Testing Library encourages accessible selectors that match how users interact with the application:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('user can complete transfer', async () => {
render(<TransferForm />);

// GOOD: Query by role and accessible name
const amountInput = screen.getByRole('textbox', { name: /amount/i });
const submitButton = screen.getByRole('button', { name: /submit transfer/i });

// AVOID: Query by test ID (doesn't verify accessibility)
// const amountInput = screen.getByTestId('amount-input');

// Interact with form
await userEvent.type(amountInput, '100.00');
await userEvent.click(submitButton);

// Verify success message is announced
const successMessage = await screen.findByRole('status');
expect(successMessage).toHaveTextContent('Transfer completed');
});

Recommended Query Priority:

  1. getByRole - Queries by ARIA role (button, textbox, heading, etc.)
  2. getByLabelText - Finds form elements by their label
  3. getByPlaceholderText - Acceptable for inputs with placeholders
  4. getByText - Finds elements by visible text content
  5. getByTestId - Last resort (doesn't verify accessibility)

If you can't find an element using accessible queries, it's likely not accessible to screen readers.

Cypress Accessibility Testing

Integrate axe-core into Cypress end-to-end tests:

npm install --save-dev cypress-axe
// cypress/support/e2e.ts
import 'cypress-axe';

// cypress/e2e/payment-flow.cy.ts
describe('Payment Flow Accessibility', () => {
beforeEach(() => {
cy.visit('/payments');
cy.injectAxe(); // Inject axe-core
});

it('should have no accessibility violations on payment page', () => {
cy.checkA11y(); // Run axe checks on entire page
});

it('should have no violations after opening modal', () => {
cy.get('button').contains('New Payment').click();
cy.checkA11y('.modal'); // Check specific element
});

it('should have no violations in form error state', () => {
cy.get('button').contains('Submit').click(); // Submit empty form
cy.checkA11y();
});

// Exclude specific elements from checks
it('should check accessibility excluding third-party widgets', () => {
cy.checkA11y({
exclude: [['.third-party-widget']] // Exclude specific selectors
});
});

// Configure specific rules
it('should check with custom configuration', () => {
cy.checkA11y(null, {
rules: {
'color-contrast': { enabled: true },
'valid-lang': { enabled: false }
}
});
});
});

For comprehensive testing strategies including manual screen reader testing, see Accessibility Guidelines - Testing.


Common Patterns

Allow keyboard users to skip repetitive navigation and jump to main content:

export function AppLayout({ children }: { children: React.ReactNode }) {
return (
<>
{/* Skip link - visually hidden until focused */}
<a href="#main-content" className="skip-link">
Skip to main content
</a>

<header>
<nav aria-label="Main navigation">
{/* Navigation links */}
</nav>
</header>

<main id="main-content" tabIndex={-1}>
{children}
</main>

<footer>
{/* Footer content */}
</footer>
</>
);
}
/* Visually hidden until focused */
.skip-link {
position: absolute;
top: -40px;
left: 0;
background: #000;
color: #fff;
padding: 8px;
z-index: 100;
}

.skip-link:focus {
top: 0;
}

Visually Hidden Content

Content visible only to screen readers:

/* Screen reader only (sr-only) utility class */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
// Usage example
<button aria-label="Delete payment">
<TrashIcon aria-hidden="true" />
<span className="sr-only">Delete payment</span>
</button>

Loading States with Announcements

export function PaymentList() {
const { data: payments, isLoading, error } = usePayments();

return (
<div>
<h1>Payments</h1>

{/* Visual loading indicator */}
{isLoading && <LoadingSpinner />}

{/* Screen reader announcement */}
<div role="status" aria-live="polite" className="sr-only">
{isLoading && 'Loading payments...'}
{!isLoading && payments && `${payments.length} payments loaded`}
{error && 'Failed to load payments'}
</div>

{/* Payment list */}
{payments && (
<ul>
{payments.map(payment => (
<li key={payment.id}>
<PaymentCard payment={payment} />
</li>
))}
</ul>
)}
</div>
);
}

Framework-Specific Resources

React

Angular


Accessibility Checklist

Use this checklist when building and reviewing frontend components:

  • All interactive elements use semantic HTML (<button>, <a>, <input>)
  • Page has proper heading hierarchy (one h1, logical h2-h6 nesting)
  • All images have appropriate alt text (or empty alt for decorative images)
  • Form inputs have associated <label> elements
  • Error messages are linked with aria-describedby and have role="alert"
  • Interactive elements have visible focus indicators
  • All functionality works with keyboard only (no mouse required)
  • Color is not the only indicator of state or information
  • Text meets WCAG AA contrast ratios (4.5:1 for normal, 3:1 for large)
  • Dynamic content changes are announced with aria-live regions
  • Modals trap focus and restore focus on close
  • Tests include jest-axe checks for accessibility violations
  • Code passes eslint-plugin-jsx-a11y linting rules

Further Reading


Summary

Frontend accessibility requires semantic HTML, proper ARIA usage, keyboard operability, and comprehensive testing. Use framework-specific tools like Angular CDK for focus management and live announcements. Integrate automated testing with ESLint and jest-axe to catch violations early. Test with actual screen readers and keyboard navigation to ensure components work for all users.

For detailed coverage of WCAG principles, mobile accessibility, screen reader testing strategies, and anti-patterns, see the Accessibility Guidelines.