Skip to main content
Back to Blog
Accessibility

React Accessibility: Best Practices Guide for WCAG-Compliant SPAs

Build accessible React applications with this comprehensive guide. Learn focus management, ARIA patterns, accessible routing, form accessibility, and testing strategies for WCAG 2.2 AA compliance.

AllAccessible Team
19 min read
ReactJavaScriptSPA AccessibilityWCAG 2.2ADA ComplianceWeb Components

React Accessibility: Best Practices Guide for WCAG-Compliant SPAs

React powers millions of modern web applications, but Single Page Applications (SPAs) introduce unique accessibility challenges that traditional websites don't face: client-side routing without page refreshes, dynamic content updates without announcements, and complex component interactions.

This guide provides comprehensive, production-ready patterns for building WCAG 2.2 AA compliant React applications with proper focus management, screen reader support, and keyboard navigation.

Why React Accessibility Matters

Legal Requirements:

  • ADA Title III applies to all web applications in the US
  • Section 508 required for government contractors
  • European Accessibility Act (EAA) now in force since June 28, 2025
  • UK Equality Act 2010 covers digital services

Business Impact:

  • 1 in 4 adults in the US has a disability (CDC)
  • 71% of users with disabilities leave inaccessible SPAs immediately
  • Higher abandonment rates with inaccessible forms and navigation
  • Legal risk: Web accessibility lawsuits increased 14% year-over-year

Common React Accessibility Violations:

  • Route changes not announced to screen readers
  • Focus not managed after navigation
  • Dynamic content updates without aria-live
  • Modal dialogs without focus trapping
  • Form errors without proper association
  • Icon-only buttons without labels
  • Keyboard navigation broken by custom components

Setting Up an Accessible React Project

Create React App with Accessibility Linting:

# Create new React app
npx create-react-app my-accessible-app
cd my-accessible-app

# Install accessibility dependencies
npm install --save-dev eslint-plugin-jsx-a11y

# Install React Router for accessible routing
npm install react-router-dom

# Install testing library for accessibility tests
npm install --save-dev @testing-library/react @testing-library/jest-dom @testing-library/user-event

# Install axe-core for automated testing
npm install --save-dev @axe-core/react

Configure ESLint for Accessibility (.eslintrc.json):

{
  "extends": [
    "react-app",
    "plugin:jsx-a11y/recommended"
  ],
  "plugins": [
    "jsx-a11y"
  ],
  "rules": {
    "jsx-a11y/anchor-is-valid": "error",
    "jsx-a11y/alt-text": "error",
    "jsx-a11y/aria-props": "error",
    "jsx-a11y/aria-proptypes": "error",
    "jsx-a11y/aria-unsupported-elements": "error",
    "jsx-a11y/click-events-have-key-events": "error",
    "jsx-a11y/heading-has-content": "error",
    "jsx-a11y/html-has-lang": "error",
    "jsx-a11y/img-redundant-alt": "error",
    "jsx-a11y/interactive-supports-focus": "error",
    "jsx-a11y/label-has-associated-control": "error",
    "jsx-a11y/no-noninteractive-element-interactions": "error",
    "jsx-a11y/role-has-required-aria-props": "error",
    "jsx-a11y/role-supports-aria-props": "error"
  }
}

Enable Runtime Accessibility Checking (src/index.js):

import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';

// Enable axe-core in development
if (process.env.NODE_ENV !== 'production') {
  const axe = require('@axe-core/react');
  axe(React, ReactDOM, 1000);
}

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Semantic JSX and ARIA

Use Semantic HTML Elements:

// ❌ BAD: Generic divs and spans
function Navigation() {
  return (
    <div className="nav">
      <div className="nav-item">Home</div>
      <div className="nav-item">About</div>
    </div>
  );
}

// βœ… GOOD: Semantic elements
function Navigation() {
  return (
    <nav aria-label="Primary navigation">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
      </ul>
    </nav>
  );
}

ARIA Landmarks:

function App() {
  return (
    <>
      <header role="banner">
        <Navigation />
      </header>

      <main role="main" id="main-content">
        <Routes>
          {/* Page content */}
        </Routes>
      </main>

      <aside role="complementary" aria-label="Sidebar">
        {/* Sidebar content */}
      </aside>

      <footer role="contentinfo">
        {/* Footer content */}
      </footer>
    </>
  );
}

Accessible Headings Hierarchy:

// ❌ BAD: Skipped heading levels
function ProductPage() {
  return (
    <>
      <h1>Product Name</h1>
      <h4>Description</h4> {/* Skips h2, h3 */}
      <h2>Reviews</h2> {/* Out of order */}
    </>
  );
}

// βœ… GOOD: Logical heading hierarchy
function ProductPage() {
  return (
    <>
      <h1>Product Name</h1>
      <h2>Description</h2>
      <h2>Reviews</h2>
      <h3>Customer Review 1</h3>
      <h3>Customer Review 2</h3>
    </>
  );
}

Focus Management in React

Skip Link Implementation:

// components/SkipLink.jsx
function SkipLink() {
  return (
    <a href="#main-content" className="skip-link">
      Skip to main content
    </a>
  );
}

// App.css
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: #000;
  color: #fff;
  padding: 8px 16px;
  text-decoration: none;
  z-index: 1000;
}

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

Focus Management After Route Changes:

// hooks/useFocusManagement.js
import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

export function useFocusManagement() {
  const location = useLocation();
  const mainContentRef = useRef(null);

  useEffect(() => {
    // Focus main content after route change
    if (mainContentRef.current) {
      mainContentRef.current.focus();
    }
  }, [location.pathname]);

  return mainContentRef;
}

// App.jsx
import { useFocusManagement } from './hooks/useFocusManagement';

function App() {
  const mainContentRef = useFocusManagement();

  return (
    <>
      <SkipLink />
      <Header />
      <main
        id="main-content"
        ref={mainContentRef}
        tabIndex={-1} // Allow programmatic focus
        className="main-content"
      >
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/products" element={<Products />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </main>
      <Footer />
    </>
  );
}

Focus After Delete Action:

function TodoList() {
  const [todos, setTodos] = useState([...]);
  const todoRefs = useRef({});

  const handleDelete = (id, index) => {
    setTodos(todos.filter(todo => todo.id !== id));

    // Focus next item, or previous if last, or add button if empty
    setTimeout(() => {
      const nextIndex = index < todos.length - 1 ? index : index - 1;
      if (todos.length > 1) {
        todoRefs.current[todos[nextIndex].id]?.focus();
      } else {
        document.getElementById('add-todo-button')?.focus();
      }
    }, 0);
  };

  return (
    <ul>
      {todos.map((todo, index) => (
        <li key={todo.id}>
          <span>{todo.text}</span>
          <button
            ref={el => todoRefs.current[todo.id] = el}
            onClick={() => handleDelete(todo.id, index)}
            aria-label={`Delete ${todo.text}`}
          >
            Delete
          </button>
        </li>
      ))}
    </ul>
  );
}

Accessible Routing with React Router

Announce Route Changes to Screen Readers:

// components/RouteAnnouncer.jsx
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';

function RouteAnnouncer() {
  const location = useLocation();

  useEffect(() => {
    // Get page title for announcement
    const pageTitle = document.title;

    // Announce to screen readers
    const announcement = document.createElement('div');
    announcement.setAttribute('role', 'status');
    announcement.setAttribute('aria-live', 'polite');
    announcement.setAttribute('aria-atomic', 'true');
    announcement.className = 'sr-only';
    announcement.textContent = `Navigated to ${pageTitle}`;

    document.body.appendChild(announcement);

    // Remove after screen reader processes
    setTimeout(() => announcement.remove(), 1000);
  }, [location.pathname]);

  return null;
}

// App.jsx
import { BrowserRouter } from 'react-router-dom';
import RouteAnnouncer from './components/RouteAnnouncer';

function App() {
  return (
    <BrowserRouter>
      <RouteAnnouncer />
      {/* Rest of app */}
    </BrowserRouter>
  );
}

Accessible Navigation Links:

// components/Navigation.jsx
import { NavLink } from 'react-router-dom';

function Navigation() {
  return (
    <nav aria-label="Primary navigation">
      <ul className="nav-list">
        <li>
          <NavLink
            to="/"
            className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
            aria-current={({ isActive }) => isActive ? 'page' : undefined}
          >
            Home
          </NavLink>
        </li>
        <li>
          <NavLink
            to="/products"
            className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
            aria-current={({ isActive }) => isActive ? 'page' : undefined}
          >
            Products
          </NavLink>
        </li>
        <li>
          <NavLink
            to="/about"
            className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}
            aria-current={({ isActive }) => isActive ? 'page' : undefined}
          >
            About
          </NavLink>
        </li>
      </ul>
    </nav>
  );
}

Accessible Link with External Indicator:

function ExternalLink({ href, children }) {
  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      aria-label={`${children} (opens in new tab)`}
    >
      {children}
      <span className="sr-only">(opens in new tab)</span>
      <svg aria-hidden="true" className="external-icon">
        {/* External link icon */}
      </svg>
    </a>
  );
}

Form Accessibility

Accessible Form with Validation:

// components/ContactForm.jsx
import { useState } from 'react';

function ContactForm() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
    message: ''
  });
  const [errors, setErrors] = useState({});
  const [submitted, setSubmitted] = useState(false);

  const handleChange = (e) => {
    const { name, value } = e.target;
    setFormData(prev => ({ ...prev, [name]: value }));

    // Clear error when user starts typing
    if (errors[name]) {
      setErrors(prev => ({ ...prev, [name]: '' }));
    }
  };

  const validate = () => {
    const newErrors = {};

    if (!formData.name.trim()) {
      newErrors.name = 'Name is required';
    }

    if (!formData.email.trim()) {
      newErrors.email = 'Email is required';
    } else if (!/\S+@\S+\.\S+/.test(formData.email)) {
      newErrors.email = 'Email is invalid';
    }

    if (!formData.message.trim()) {
      newErrors.message = 'Message is required';
    } else if (formData.message.length < 10) {
      newErrors.message = 'Message must be at least 10 characters';
    }

    return newErrors;
  };

  const handleSubmit = (e) => {
    e.preventDefault();

    const newErrors = validate();

    if (Object.keys(newErrors).length > 0) {
      setErrors(newErrors);

      // Focus first error field
      const firstErrorField = Object.keys(newErrors)[0];
      document.getElementById(firstErrorField)?.focus();
    } else {
      // Submit form
      console.log('Form submitted:', formData);
      setSubmitted(true);

      // Reset form
      setFormData({ name: '', email: '', message: '' });
    }
  };

  return (
    <form onSubmit={handleSubmit} noValidate>
      <h2>Contact Us</h2>

      {/* Success message */}
      {submitted && (
        <div
          role="status"
          aria-live="polite"
          className="success-message"
        >
          Thank you! Your message has been sent.
        </div>
      )}

      {/* Error summary */}
      {Object.keys(errors).length > 0 && (
        <div
          role="alert"
          aria-live="assertive"
          className="error-summary"
        >
          <h3>Please fix the following errors:</h3>
          <ul>
            {Object.entries(errors).map(([field, error]) => (
              <li key={field}>
                <a href={`#${field}`}>{error}</a>
              </li>
            ))}
          </ul>
        </div>
      )}

      {/* Name field */}
      <div className="form-field">
        <label htmlFor="name">
          Name <span className="required" aria-label="required">*</span>
        </label>
        <input
          type="text"
          id="name"
          name="name"
          value={formData.name}
          onChange={handleChange}
          required
          aria-required="true"
          aria-invalid={!!errors.name}
          aria-describedby={errors.name ? 'name-error' : undefined}
          autoComplete="name"
        />
        {errors.name && (
          <span id="name-error" className="field-error" role="alert">
            {errors.name}
          </span>
        )}
      </div>

      {/* Email field */}
      <div className="form-field">
        <label htmlFor="email">
          Email <span className="required" aria-label="required">*</span>
        </label>
        <input
          type="email"
          id="email"
          name="email"
          value={formData.email}
          onChange={handleChange}
          required
          aria-required="true"
          aria-invalid={!!errors.email}
          aria-describedby={errors.email ? 'email-error' : undefined}
          autoComplete="email"
        />
        {errors.email && (
          <span id="email-error" className="field-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      {/* Message field */}
      <div className="form-field">
        <label htmlFor="message">
          Message <span className="required" aria-label="required">*</span>
        </label>
        <textarea
          id="message"
          name="message"
          value={formData.message}
          onChange={handleChange}
          rows="6"
          required
          aria-required="true"
          aria-invalid={!!errors.message}
          aria-describedby={errors.message ? 'message-error message-hint' : 'message-hint'}
        />
        <span id="message-hint" className="field-hint">
          Minimum 10 characters
        </span>
        {errors.message && (
          <span id="message-error" className="field-error" role="alert">
            {errors.message}
          </span>
        )}
      </div>

      <button type="submit" className="btn btn-primary">
        Send Message
      </button>
    </form>
  );
}

Accessible Select Dropdown:

function CountrySelect({ value, onChange, error }) {
  const countries = [
    { code: 'US', name: 'United States' },
    { code: 'CA', name: 'Canada' },
    { code: 'UK', name: 'United Kingdom' },
    // ... more countries
  ];

  return (
    <div className="form-field">
      <label htmlFor="country">
        Country <span className="required" aria-label="required">*</span>
      </label>
      <select
        id="country"
        name="country"
        value={value}
        onChange={onChange}
        required
        aria-required="true"
        aria-invalid={!!error}
        aria-describedby={error ? 'country-error' : undefined}
        autoComplete="country"
      >
        <option value="">Select a country</option>
        {countries.map(country => (
          <option key={country.code} value={country.code}>
            {country.name}
          </option>
        ))}
      </select>
      {error && (
        <span id="country-error" className="field-error" role="alert">
          {error}
        </span>
      )}
    </div>
  );
}

Modal Dialog Accessibility

Accessible Modal Component:

// components/Modal.jsx
import { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef(null);
  const previousFocusRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      // Save current focus
      previousFocusRef.current = document.activeElement;

      // Focus modal
      modalRef.current?.focus();

      // Prevent background scrolling
      document.body.style.overflow = 'hidden';

      // Trap focus
      const handleKeyDown = (e) => {
        if (e.key === 'Escape') {
          onClose();
        }

        if (e.key === 'Tab') {
          trapFocus(e, modalRef.current);
        }
      };

      document.addEventListener('keydown', handleKeyDown);

      return () => {
        document.removeEventListener('keydown', handleKeyDown);
        document.body.style.overflow = '';

        // Restore focus
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen, onClose]);

  const trapFocus = (e, element) => {
    const focusableElements = element.querySelectorAll(
      'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
    );
    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    if (e.shiftKey) {
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement.focus();
      }
    } else {
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement.focus();
      }
    }
  };

  if (!isOpen) return null;

  return createPortal(
    <>
      {/* Backdrop */}
      <div
        className="modal-backdrop"
        onClick={onClose}
        aria-hidden="true"
      />

      {/* Modal */}
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        className="modal"
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close modal"
            className="modal-close"
          >
            <span aria-hidden="true">&times;</span>
          </button>
        </div>

        <div className="modal-body">
          {children}
        </div>

        <div className="modal-footer">
          <button onClick={onClose} className="btn btn-secondary">
            Cancel
          </button>
          <button className="btn btn-primary">
            Confirm
          </button>
        </div>
      </div>
    </>,
    document.body
  );
}

// Usage
function App() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsModalOpen(true)}>
        Open Modal
      </button>

      <Modal
        isOpen={isModalOpen}
        onClose={() => setIsModalOpen(false)}
        title="Confirm Action"
      >
        <p>Are you sure you want to proceed?</p>
      </Modal>
    </>
  );
}

Live Regions for Dynamic Content

Accessible Search with Results Announcement:

// components/Search.jsx
import { useState, useEffect } from 'react';

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [announcement, setAnnouncement] = useState('');

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    setIsLoading(true);

    // Debounce search
    const timeoutId = setTimeout(async () => {
      const data = await fetchResults(query);
      setResults(data);
      setIsLoading(false);

      // Announce results to screen readers
      const count = data.length;
      setAnnouncement(
        count === 0
          ? 'No results found'
          : `${count} result${count !== 1 ? 's' : ''} found`
      );
    }, 500);

    return () => clearTimeout(timeoutId);
  }, [query]);

  return (
    <div className="search">
      <label htmlFor="search-input">Search</label>
      <input
        type="search"
        id="search-input"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        aria-controls="search-results"
        aria-describedby="search-status"
        autoComplete="off"
      />

      {/* Loading indicator */}
      {isLoading && (
        <div
          id="search-status"
          role="status"
          aria-live="polite"
          className="sr-only"
        >
          Loading results...
        </div>
      )}

      {/* Results announcement */}
      {!isLoading && announcement && (
        <div
          id="search-status"
          role="status"
          aria-live="polite"
          aria-atomic="true"
          className="sr-only"
        >
          {announcement}
        </div>
      )}

      {/* Results list */}
      <ul id="search-results" role="list">
        {results.map(result => (
          <li key={result.id}>
            <a href={result.url}>{result.title}</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

Toast Notification Component:

// components/Toast.jsx
import { useEffect, useState } from 'react';

function Toast({ message, type = 'info', duration = 3000, onClose }) {
  const [isVisible, setIsVisible] = useState(true);

  useEffect(() => {
    const timer = setTimeout(() => {
      setIsVisible(false);
      onClose?.();
    }, duration);

    return () => clearTimeout(timer);
  }, [duration, onClose]);

  if (!isVisible) return null;

  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className={`toast toast-${type}`}
    >
      <span>{message}</span>
      <button
        onClick={() => {
          setIsVisible(false);
          onClose?.();
        }}
        aria-label="Dismiss notification"
        className="toast-close"
      >
        <span aria-hidden="true">&times;</span>
      </button>
    </div>
  );
}

// Usage with Toast Manager
function ToastManager() {
  const [toasts, setToasts] = useState([]);

  const addToast = (message, type = 'info') => {
    const id = Date.now();
    setToasts(prev => [...prev, { id, message, type }]);
  };

  const removeToast = (id) => {
    setToasts(prev => prev.filter(toast => toast.id !== id));
  };

  return (
    <>
      <button onClick={() => addToast('Item saved successfully', 'success')}>
        Save Item
      </button>

      <div className="toast-container" aria-live="polite" aria-atomic="false">
        {toasts.map(toast => (
          <Toast
            key={toast.id}
            message={toast.message}
            type={toast.type}
            onClose={() => removeToast(toast.id)}
          />
        ))}
      </div>
    </>
  );
}

Accessible Component Patterns

Accordion Component:

// components/Accordion.jsx
import { useState } from 'react';

function AccordionItem({ id, title, children }) {
  const [isOpen, setIsOpen] = useState(false);

  return (
    <div className="accordion-item">
      <h3>
        <button
          id={`accordion-button-${id}`}
          aria-expanded={isOpen}
          aria-controls={`accordion-panel-${id}`}
          onClick={() => setIsOpen(!isOpen)}
          className="accordion-button"
        >
          {title}
          <span aria-hidden="true" className="accordion-icon">
            {isOpen ? 'βˆ’' : '+'}
          </span>
        </button>
      </h3>
      <div
        id={`accordion-panel-${id}`}
        role="region"
        aria-labelledby={`accordion-button-${id}`}
        hidden={!isOpen}
        className="accordion-panel"
      >
        {children}
      </div>
    </div>
  );
}

function Accordion({ items }) {
  return (
    <div className="accordion">
      {items.map((item, index) => (
        <AccordionItem key={index} id={index} title={item.title}>
          {item.content}
        </AccordionItem>
      ))}
    </div>
  );
}

Tabs Component:

// components/Tabs.jsx
import { useState, useRef, useEffect } from 'react';

function Tabs({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);
  const tabRefs = useRef([]);

  const handleKeyDown = (e, index) => {
    let newIndex = index;

    switch (e.key) {
      case 'ArrowLeft':
        e.preventDefault();
        newIndex = index > 0 ? index - 1 : tabs.length - 1;
        break;
      case 'ArrowRight':
        e.preventDefault();
        newIndex = index < tabs.length - 1 ? index + 1 : 0;
        break;
      case 'Home':
        e.preventDefault();
        newIndex = 0;
        break;
      case 'End':
        e.preventDefault();
        newIndex = tabs.length - 1;
        break;
      default:
        return;
    }

    setActiveTab(newIndex);
    tabRefs.current[newIndex]?.focus();
  };

  return (
    <div className="tabs">
      <div role="tablist" aria-label="Content tabs">
        {tabs.map((tab, index) => (
          <button
            key={index}
            ref={el => tabRefs.current[index] = el}
            role="tab"
            id={`tab-${index}`}
            aria-selected={activeTab === index}
            aria-controls={`panel-${index}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            className={activeTab === index ? 'tab active' : 'tab'}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {tabs.map((tab, index) => (
        <div
          key={index}
          role="tabpanel"
          id={`panel-${index}`}
          aria-labelledby={`tab-${index}`}
          hidden={activeTab !== index}
          tabIndex={0}
          className="tab-panel"
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

Accessible Button Component:

// components/Button.jsx
function Button({
  children,
  onClick,
  type = 'button',
  variant = 'primary',
  disabled = false,
  isLoading = false,
  ariaLabel,
  ...props
}) {
  return (
    <button
      type={type}
      onClick={onClick}
      disabled={disabled || isLoading}
      aria-label={ariaLabel}
      aria-busy={isLoading}
      className={`btn btn-${variant}`}
      {...props}
    >
      {isLoading ? (
        <>
          <span className="spinner" aria-hidden="true"></span>
          <span className="sr-only">Loading...</span>
        </>
      ) : (
        children
      )}
    </button>
  );
}

// Usage
<Button onClick={handleSave} isLoading={isSaving}>
  Save Changes
</Button>

<Button
  onClick={handleDelete}
  variant="danger"
  ariaLabel="Delete item"
>
  <TrashIcon aria-hidden="true" />
</Button>

Testing React Apps for Accessibility

Automated Testing with Jest and Testing Library:

// ContactForm.test.jsx
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';
import ContactForm from './ContactForm';

expect.extend(toHaveNoViolations);

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

  test('labels are associated with inputs', () => {
    render(<ContactForm />);

    const nameInput = screen.getByLabelText(/name/i);
    const emailInput = screen.getByLabelText(/email/i);
    const messageInput = screen.getByLabelText(/message/i);

    expect(nameInput).toBeInTheDocument();
    expect(emailInput).toBeInTheDocument();
    expect(messageInput).toBeInTheDocument();
  });

  test('form errors are announced to screen readers', async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    const submitButton = screen.getByRole('button', { name: /send message/i });
    await user.click(submitButton);

    // Error summary should have role="alert"
    const errorSummary = screen.getByRole('alert');
    expect(errorSummary).toBeInTheDocument();

    // Individual field errors should be associated
    const nameInput = screen.getByLabelText(/name/i);
    expect(nameInput).toHaveAttribute('aria-invalid', 'true');
    expect(nameInput).toHaveAttribute('aria-describedby');
  });

  test('focus moves to first error on invalid submission', async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    const submitButton = screen.getByRole('button', { name: /send message/i });
    await user.click(submitButton);

    await waitFor(() => {
      const nameInput = screen.getByLabelText(/name/i);
      expect(nameInput).toHaveFocus();
    });
  });

  test('success message is announced to screen readers', async () => {
    const user = userEvent.setup();
    render(<ContactForm />);

    // Fill form
    await user.type(screen.getByLabelText(/name/i), 'John Doe');
    await user.type(screen.getByLabelText(/email/i), 'john@example.com');
    await user.type(screen.getByLabelText(/message/i), 'This is a test message');

    // Submit
    await user.click(screen.getByRole('button', { name: /send message/i }));

    // Success message should have role="status"
    await waitFor(() => {
      const successMessage = screen.getByRole('status');
      expect(successMessage).toHaveTextContent(/thank you/i);
    });
  });
});

Keyboard Navigation Testing:

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

describe('Modal Keyboard Navigation', () => {
  test('focus is trapped within modal', async () => {
    const user = userEvent.setup();
    const onClose = jest.fn();

    render(
      <div>
        <button>Outside Button</button>
        <Modal isOpen={true} onClose={onClose} title="Test Modal">
          <button>First Button</button>
          <button>Last Button</button>
        </Modal>
      </div>
    );

    const firstButton = screen.getByRole('button', { name: /first button/i });
    const lastButton = screen.getByRole('button', { name: /last button/i });

    // Focus should start on modal
    const modal = screen.getByRole('dialog');
    expect(modal).toHaveFocus();

    // Tab to first button
    await user.tab();
    expect(firstButton).toHaveFocus();

    // Tab to last button
    await user.tab();
    expect(lastButton).toHaveFocus();

    // Tab should wrap to first button
    await user.tab();
    expect(firstButton).toHaveFocus();

    // Shift+Tab should go to last button
    await user.tab({ shift: true });
    expect(lastButton).toHaveFocus();
  });

  test('Escape key closes modal', async () => {
    const user = userEvent.setup();
    const onClose = jest.fn();

    render(
      <Modal isOpen={true} onClose={onClose} title="Test Modal">
        <p>Modal content</p>
      </Modal>
    );

    await user.keyboard('{Escape}');
    expect(onClose).toHaveBeenCalledTimes(1);
  });
});

Screen Reader Testing Checklist:

Manual Screen Reader Testing (NVDA/JAWS/VoiceOver):

β–‘ All images have alt text or are marked decorative
β–‘ Form labels are read with inputs
β–‘ Error messages are announced
β–‘ Buttons have clear purposes
β–‘ Links have descriptive text (not "click here")
β–‘ Headings create logical document outline
β–‘ Landmarks identify page regions
β–‘ Dynamic content updates are announced
β–‘ Modal dialogs announced when opened
β–‘ Route changes are announced
β–‘ Loading states are announced
β–‘ Form submission results are announced

Common React Accessibility Issues

Issue 1: Missing Alt Text on Images

// ❌ BAD: No alt text
<img src="/product.jpg" />

// βœ… GOOD: Descriptive alt text
<img src="/product.jpg" alt="Blue cotton t-shirt with pocket" />

// βœ… GOOD: Decorative image
<img src="/decoration.svg" alt="" role="presentation" />

Issue 2: onClick on Non-Interactive Elements

// ❌ BAD: onClick on div (not keyboard accessible)
<div onClick={handleClick}>Click me</div>

// βœ… GOOD: Use button
<button onClick={handleClick}>Click me</button>

// βœ… ALTERNATIVE: Add keyboard support to div
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      handleClick();
    }
  }}
>
  Click me
</div>

Issue 3: Icon-Only Buttons

// ❌ BAD: No text alternative
<button onClick={handleDelete}>
  <TrashIcon />
</button>

// βœ… GOOD: aria-label provides context
<button onClick={handleDelete} aria-label="Delete item">
  <TrashIcon aria-hidden="true" />
</button>

// βœ… BETTER: Visible text with icon
<button onClick={handleDelete}>
  <TrashIcon aria-hidden="true" />
  Delete
</button>

Issue 4: Redundant ARIA

// ❌ BAD: Redundant role and aria-label
<button role="button" aria-label="Submit">Submit</button>

// βœ… GOOD: Button role is implicit
<button>Submit</button>

// βœ… GOOD: Only use aria-label when needed
<button aria-label="Submit form">
  <PaperPlaneIcon aria-hidden="true" />
</button>

Issue 5: Not Managing Focus After Delete

// ❌ BAD: Focus disappears after delete
function handleDelete(id) {
  setItems(items.filter(item => item.id !== id));
}

// βœ… GOOD: Focus moves to logical next element
function handleDelete(id, index) {
  setItems(items.filter(item => item.id !== id));

  // Focus next item or previous if last
  setTimeout(() => {
    const nextItem = items[index + 1] || items[index - 1];
    if (nextItem) {
      document.getElementById(`item-${nextItem.id}`)?.focus();
    }
  }, 0);
}

Integrating AllAccessible with React

AllAccessible provides automated WCAG compliance for React applications with minimal integration effort.

Installation:

npm install @allaccessible/react

Basic Integration:

// App.jsx
import { AllAccessibleWidget } from '@allaccessible/react';

function App() {
  return (
    <>
      <AllAccessibleWidget
        siteId="your-site-id"
        position="bottom-right"
      />

      {/* Your app content */}
      <Routes>
        {/* Routes */}
      </Routes>
    </>
  );
}

Advanced Configuration:

import { AllAccessibleWidget } from '@allaccessible/react';

function App() {
  return (
    <>
      <AllAccessibleWidget
        siteId="your-site-id"
        position="bottom-right"
        config={{
          enableKeyboardNavigation: true,
          enableScreenReaderMode: true,
          enableTextAdjustments: true,
          enableColorAdjustments: true,
          enableFocusHighlight: true,
          customColors: {
            primary: '#0066cc',
            secondary: '#6c757d'
          }
        }}
      />

      <YourApp />
    </>
  );
}

AllAccessible Features:

  • βœ… Automatic ARIA fixes - Adds missing labels, roles, states
  • βœ… Keyboard navigation - Ensures all interactive elements keyboard accessible
  • βœ… Screen reader support - Announces dynamic content changes
  • βœ… Focus management - Enhanced visible focus indicators
  • βœ… Color adjustments - Real-time contrast fixes
  • βœ… Text sizing - User-controlled text scaling (up to 200%)
  • βœ… Alt text generation - AI-powered alt text for images
  • βœ… Compliance reporting - WCAG 2.2 AA compliance certificate

Pricing:

  • $10/month per domain
  • 7-day free trial
  • Cancel anytime
  • WCAG 2.2 AA compliance guarantee

Sign up: allaccessible.org

Conclusion

Building accessible React applications requires intentional design patterns and continuous testing, but the legal compliance and improved user experience make it essential.

Key Takeaways:

  1. βœ… Enable accessibility linting - Use eslint-plugin-jsx-a11y
  2. βœ… Use semantic HTML - Prefer <button> over <div onClick>
  3. βœ… Manage focus - After route changes, modals, deletions
  4. βœ… Announce changes - Use aria-live for dynamic content
  5. βœ… Trap focus in modals - Prevent keyboard users from leaving
  6. βœ… Associate labels with inputs - Use htmlFor and id
  7. βœ… Test with keyboard - Complete all tasks without mouse
  8. βœ… Test with screen readers - NVDA, JAWS, VoiceOver
  9. βœ… Automate testing - Use jest-axe in your test suite
  10. βœ… Consider AllAccessible - Automated compliance for $10/month

Quick Start:

  • Today: Install eslint-plugin-jsx-a11y and fix linting errors
  • This week: Add focus management to route changes
  • This month: Implement comprehensive accessibility testing
  • Ongoing: Test with real assistive technology users

Your users deserve an accessible experience - whether they use a mouse, keyboard, screen reader, or other assistive technology. Start implementing these patterns today.

Questions? Contact our accessibility team at support@allaccessible.org.


Last updated: December 2025 | WCAG 2.2 Compliant | Reviewed by certified accessibility specialists

Share this article