Back to articles
Accessibility

Web Accessibility in Modern React Applications

Complete guide to making React applications accessible. Learn about WCAG guidelines, semantic HTML, ARIA attributes, and testing for accessibility.

14 mins read
AccessibilityReactWCAGA11y

Web accessibility ensures that your React applications are usable by everyone, including people with disabilities. This comprehensive guide covers WCAG guidelines, semantic HTML, ARIA attributes, and testing strategies.

Understanding WCAG Guidelines

The Web Content Accessibility Guidelines (WCAG) 2.1 provide the foundation for web accessibility. They are organized around four principles: Perceivable, Operable, Understandable, and Robust (POUR).

  • Perceivable: Information must be presentable in ways users can perceive
  • Operable: Interface components must be operable by all users
  • Understandable: Information and UI operation must be understandable
  • Robust: Content must be robust enough for various assistive technologies

Semantic HTML and ARIA

ReactAccessibleForm.tsx
interface FormFieldProps {  label: string;  id: string;  type?: string;  required?: boolean;  error?: string;  helpText?: string;  value: string;  onChange: (value: string) => void;}export function AccessibleFormField({  label,  id,  type = 'text',  required = false,  error,  helpText,  value,  onChange}: FormFieldProps) {  const helpTextId = helpText ? `${id}-help` : undefined;  const errorId = error ? `${id}-error` : undefined;    return (    <div className="form-field">      <label         htmlFor={id}        className="block text-sm font-medium text-gray-700"      >        {label}        {required && (          <span             className="text-red-500 ml-1"             aria-label="required"          >            *          </span>        )}      </label>            <input        id={id}        type={type}        required={required}        value={value}        onChange={e => onChange(e.target.value)}        aria-describedby={[helpTextId, errorId].filter(Boolean).join(' ') || undefined}        aria-invalid={!!error}        className={`          mt-1 block w-full px-3 py-2 border rounded-md shadow-sm          focus:outline-none focus:ring-2 focus:ring-blue-500          ${error ? 'border-red-500' : 'border-gray-300'}        `}      />            {helpText && (        <p id={helpTextId} className="mt-1 text-sm text-gray-600">          {helpText}        </p>      )}            {error && (        <p           id={errorId}           role="alert"           className="mt-1 text-sm text-red-600"        >          {error}        </p>      )}    </div>  );}

Keyboard Navigation

ReactAccessibleModal.tsx
import { useEffect, useRef } from 'react';import { createPortal } from 'react-dom';interface ModalProps {  isOpen: boolean;  onClose: () => void;  title: string;  children: React.ReactNode;}export function AccessibleModal({ isOpen, onClose, title, children }: ModalProps) {  const modalRef = useRef<HTMLDivElement>(null);  const previousActiveElement = useRef<HTMLElement | null>(null);  useEffect(() => {    if (isOpen) {      // Store the currently focused element      previousActiveElement.current = document.activeElement as HTMLElement;            // Focus the modal      modalRef.current?.focus();            // Prevent body scroll      document.body.style.overflow = 'hidden';    } else {      // Restore focus to the previously focused element      previousActiveElement.current?.focus();            // Restore body scroll      document.body.style.overflow = 'unset';    }        return () => {      document.body.style.overflow = 'unset';    };  }, [isOpen]);  const handleKeyDown = (event: React.KeyboardEvent) => {    if (event.key === 'Escape') {      onClose();    }        // Trap focus within modal    if (event.key === 'Tab') {      const focusableElements = modalRef.current?.querySelectorAll(        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'      );            if (focusableElements && focusableElements.length > 0) {        const firstElement = focusableElements[0] as HTMLElement;        const lastElement = focusableElements[focusableElements.length - 1] as HTMLElement;                if (event.shiftKey && document.activeElement === firstElement) {          event.preventDefault();          lastElement.focus();        } else if (!event.shiftKey && document.activeElement === lastElement) {          event.preventDefault();          firstElement.focus();        }      }    }  };  if (!isOpen) return null;  return createPortal(    <div      className="fixed inset-0 z-50 flex items-center justify-center"      role="dialog"      aria-modal="true"      aria-labelledby="modal-title"    >      {/* Backdrop */}      <div         className="fixed inset-0 bg-black bg-opacity-50"        onClick={onClose}        aria-hidden="true"      />            {/* Modal */}      <div        ref={modalRef}        className="relative bg-white rounded-lg p-6 max-w-md w-full mx-4 shadow-lg"        onKeyDown={handleKeyDown}        tabIndex={-1}      >        <div className="flex justify-between items-center mb-4">          <h2 id="modal-title" className="text-lg font-semibold">            {title}          </h2>          <button            onClick={onClose}            className="text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"            aria-label="Close modal"          >            <span aria-hidden="true">&times;</span>          </button>        </div>                <div className="modal-content">          {children}        </div>      </div>    </div>,    document.body  );}

Screen Reader Support

ReactAccessibleDataTable.tsx
interface TableData {  id: string;  name: string;  email: string;  role: string;  status: 'active' | 'inactive';}interface AccessibleTableProps {  data: TableData[];  caption: string;  onSort: (column: keyof TableData) => void;  sortColumn?: keyof TableData;  sortDirection?: 'asc' | 'desc';}export function AccessibleDataTable({  data,  caption,  onSort,  sortColumn,  sortDirection}: AccessibleTableProps) {  const getSortButtonLabel = (column: keyof TableData) => {    if (sortColumn === column) {      return `Sort by ${column} ${sortDirection === 'asc' ? 'descending' : 'ascending'}`;    }    return `Sort by ${column}`;  };  const getSortIcon = (column: keyof TableData) => {    if (sortColumn !== column) {      return <span aria-hidden="true">↕</span>;    }    return sortDirection === 'asc' ?       <span aria-hidden="true">↑</span> :       <span aria-hidden="true">↓</span>;  };  return (    <table className="w-full border-collapse border border-gray-300">      <caption className="sr-only">        {caption}      </caption>            <thead>        <tr className="bg-gray-50">          {(['name', 'email', 'role', 'status'] as const).map(column => (            <th               key={column}              scope="col"              className="border border-gray-300 px-4 py-2 text-left"            >              <button                onClick={() => onSort(column)}                className="flex items-center gap-1 font-semibold hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"                aria-label={getSortButtonLabel(column)}              >                {column.charAt(0).toUpperCase() + column.slice(1)}                {getSortIcon(column)}              </button>            </th>          ))}        </tr>      </thead>            <tbody>        {data.map(row => (          <tr key={row.id} className="hover:bg-gray-50">            <td className="border border-gray-300 px-4 py-2">              {row.name}            </td>            <td className="border border-gray-300 px-4 py-2">              {row.email}            </td>            <td className="border border-gray-300 px-4 py-2">              {row.role}            </td>            <td className="border border-gray-300 px-4 py-2">              <span                 className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${                  row.status === 'active'                     ? 'bg-green-100 text-green-800'                     : 'bg-red-100 text-red-800'                }`}                aria-label={`Status: ${row.status}`}              >                {row.status}              </span>            </td>          </tr>        ))}      </tbody>    </table>  );}

Accessibility Testing

Regular accessibility testing is crucial for maintaining an inclusive application. Use both automated tools and manual testing techniques.

Reactaccessibility.test.tsx
import { render, screen } from '@testing-library/react';import { axe, toHaveNoViolations } from 'jest-axe';import userEvent from '@testing-library/user-event';import { AccessibleModal } from './AccessibleModal';// Extend Jest matchersexpect.extend(toHaveNoViolations);describe('AccessibleModal', () => {  it('should not have accessibility violations', async () => {    const { container } = render(      <AccessibleModal isOpen={true} onClose={() => {}} title="Test Modal">        <p>Modal content</p>        <button>Action Button</button>      </AccessibleModal>    );        const results = await axe(container);    expect(results).toHaveNoViolations();  });  it('should trap focus within modal', async () => {    const user = userEvent.setup();    const onClose = jest.fn();        render(      <AccessibleModal isOpen={true} onClose={onClose} title="Test Modal">        <button data-testid="first-button">First Button</button>        <button data-testid="second-button">Second Button</button>      </AccessibleModal>    );    const firstButton = screen.getByTestId('first-button');    const secondButton = screen.getByTestId('second-button');    const closeButton = screen.getByLabelText('Close modal');    // Test forward tab cycling    firstButton.focus();    await user.tab();    expect(secondButton).toHaveFocus();        await user.tab();    expect(closeButton).toHaveFocus();        await user.tab();    expect(firstButton).toHaveFocus(); // Should cycle back    // Test backward tab cycling    await user.tab({ shift: true });    expect(closeButton).toHaveFocus();  });  it('should close on Escape key', async () => {    const user = userEvent.setup();    const onClose = jest.fn();        render(      <AccessibleModal isOpen={true} onClose={onClose} title="Test Modal">        <p>Modal content</p>      </AccessibleModal>    );    await user.keyboard('{Escape}');    expect(onClose).toHaveBeenCalled();  });});

Common Accessibility Patterns

  • Use semantic HTML elements (button, nav, main, aside, etc.)
  • Provide alternative text for images with meaningful content
  • Ensure sufficient color contrast (4.5:1 for normal text)
  • Make all interactive elements keyboard accessible
  • Use ARIA labels and descriptions where needed
  • Implement skip links for keyboard navigation
  • Test with screen readers and keyboard-only navigation
  • Provide clear error messages and form validation
Semantic HTML

Use proper HTML elements for better screen reader support and structure.

ARIA Attributes

Enhance accessibility with proper ARIA labels, roles, and properties.

Keyboard Navigation

Ensure all interactive elements are accessible via keyboard.

Testing & Validation

Automated and manual testing for WCAG compliance.

Accessibility is not a feature you add at the end—it should be built into your development process from the beginning. When you design for accessibility, you create better experiences for everyone.