Skip to main content

Welcome to our new website! The content and organization have been heavily redone and we want to hear from you! 
Submit feedback

React Accessibility Guide

Build inclusive React applications from the start.

⚛️ For React developers

Accessibility in React apps

React has good accessibility defaults, but dynamic SPAs introduce unique challenges. This guide covers React-specific patterns and gotchas.

💡 The good news

React renders standard HTML. All your HTML accessibility knowledge applies! The challenge is managing focus, announcements, and dynamic content.

JSX accessibility basics

Use htmlFor, not for

// ❌ Won't work - for is a reserved word
<label for="email">Email</label>

// ✅ Use htmlFor
<label htmlFor="email">Email</label>
<input id="email" type="email" />

Use className, not class

// ❌ class is reserved
<div class="card">

// ✅ Use className
<div className="card">

ARIA in JSX

// camelCase for data-* and aria-* attributes
<button
  aria-expanded={isOpen}
  aria-controls="menu-content"
  aria-label="Toggle menu"
>
  Menu
</button>

Boolean ARIA attributes

// ✅ Pass actual booleans - React handles conversion
<button aria-pressed={isActive}>Toggle</button>

// Renders as aria-pressed="true" or aria-pressed="false"

Semantic HTML in components

Fragments preserve semantics

// ❌ Extra div breaks list semantics
function ListItems() {
  return (
    <div>
      <li>Item 1</li>
      <li>Item 2</li>
    </div>
  );
}

// ✅ Fragment preserves semantics
function ListItems() {
  return (
    <>
      <li>Item 1</li>
      <li>Item 2</li>
    </>
  );
}

Heading levels in components

// ❌ Hardcoded heading level breaks hierarchy
function Card({ title, children }) {
  return (
    <article>
      <h2>{title}</h2>  {/* Always h2? */}
      {children}
    </article>
  );
}

// ✅ Configurable heading level
function Card({ title, children, headingLevel = 2 }) {
  const Heading = `h${headingLevel}`;
  return (
    <article>
      <Heading>{title}</Heading>
      {children}
    </article>
  );
}

// Usage
<Card title="Features" headingLevel={3} />

Focus management

SPAs don't trigger page loads, so you must manage focus manually on route changes.

Focus on route change

import { useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';

function App() {
  const mainRef = useRef(null);
  const location = useLocation();

  useEffect(() => {
    // Focus main content on route change
    mainRef.current?.focus();
  }, [location.pathname]);

  return (
    <>
      <nav>...</nav>
      <main ref={mainRef} tabIndex={-1}>
        {/* Page content */}
      </main>
    </>
  );
}

Focus after actions

function DeleteButton({ itemId, onDelete }) {
  const handleDelete = async () => {
    await deleteItem(itemId);
    onDelete(); // Parent moves focus to next item
  };

  return <button onClick={handleDelete}>Delete</button>;
}

function ItemList() {
  const itemRefs = useRef({});

  const handleDelete = (deletedIndex) => {
    // Focus next item, or previous if last
    const nextIndex = Math.min(deletedIndex, items.length - 2);
    itemRefs.current[nextIndex]?.focus();
  };

  return (
    <ul>
      {items.map((item, i) => (
        <li key={item.id} ref={el => itemRefs.current[i] = el} tabIndex={-1}>
          {item.name}
          <DeleteButton itemId={item.id} onDelete={() => handleDelete(i)} />
        </li>
      ))}
    </ul>
  );
}

Live announcements

Announce dynamic changes to screen reader users.

Live region component

function LiveAnnouncer({ message, politeness = 'polite' }) {
  return (
    <div
      role="status"
      aria-live={politeness}
      aria-atomic="true"
      className="sr-only"
    >
      {message}
    </div>
  );
}

// CSS
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

Using a context for app-wide announcements

const AnnouncerContext = createContext();

export function AnnouncerProvider({ children }) {
  const [message, setMessage] = useState('');

  const announce = useCallback((text, politeness = 'polite') => {
    setMessage(''); // Clear first to ensure re-announcement
    setTimeout(() => setMessage(text), 50);
  }, []);

  return (
    <AnnouncerContext.Provider value={{ announce }}>
      {children}
      <LiveAnnouncer message={message} />
    </AnnouncerContext.Provider>
  );
}

// Usage in any component
function SearchResults({ results }) {
  const { announce } = useContext(AnnouncerContext);

  useEffect(() => {
    announce(`${results.length} results found`);
  }, [results.length, announce]);

  return /* ... */;
}
import { useEffect, useRef, useCallback } from 'react';
import { createPortal } from 'react-dom';

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

  // Store and restore focus
  useEffect(() => {
    if (isOpen) {
      previousActiveElement.current = document.activeElement;
      modalRef.current?.focus();
    } else {
      previousActiveElement.current?.focus();
    }
  }, [isOpen]);

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

    if (e.key !== 'Tab') return;

    const focusables = modalRef.current.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    const first = focusables[0];
    const last = focusables[focusables.length - 1];

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  }, [onClose]);

  // Prevent background scroll
  useEffect(() => {
    if (isOpen) {
      document.body.style.overflow = 'hidden';
      return () => { document.body.style.overflow = ''; };
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()}
        onKeyDown={handleKeyDown}
        className="modal"
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>,
    document.body
  );
}

Accessible forms

Form field component

function FormField({ 
  label, 
  id, 
  error, 
  hint, 
  required,
  ...inputProps 
}) {
  const hintId = hint ? `${id}-hint` : undefined;
  const errorId = error ? `${id}-error` : undefined;
  const describedBy = [hintId, errorId].filter(Boolean).join(' ') || undefined;

  return (
    <div className="form-field">
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>
      
      {hint && <p id={hintId} className="hint">{hint}</p>}
      
      <input
        id={id}
        aria-describedby={describedBy}
        aria-invalid={error ? 'true' : undefined}
        aria-required={required}
        {...inputProps}
      />
      
      {error && (
        <p id={errorId} className="error" role="alert">
          {error}
        </p>
      )}
    </div>
  );
}

// Usage
<FormField
  label="Email address"
  id="email"
  type="email"
  required
  hint="We'll never share your email."
  error={errors.email}
  value={email}
  onChange={(e) => setEmail(e.target.value)}
/>

Useful accessibility hooks

useFocusTrap

function useFocusTrap(ref, isActive) {
  useEffect(() => {
    if (!isActive || !ref.current) return;

    const element = ref.current;
    const focusables = element.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    
    const first = focusables[0];
    const last = focusables[focusables.length - 1];

    const handleTab = (e) => {
      if (e.key !== 'Tab') return;
      
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    };

    element.addEventListener('keydown', handleTab);
    return () => element.removeEventListener('keydown', handleTab);
  }, [ref, isActive]);
}

useReducedMotion

function useReducedMotion() {
  const [reducedMotion, setReducedMotion] = useState(false);

  useEffect(() => {
    const query = window.matchMedia('(prefers-reduced-motion: reduce)');
    setReducedMotion(query.matches);

    const handler = (e) => setReducedMotion(e.matches);
    query.addEventListener('change', handler);
    return () => query.removeEventListener('change', handler);
  }, []);

  return reducedMotion;
}

// Usage
function AnimatedComponent() {
  const reducedMotion = useReducedMotion();

  return (
    <motion.div
      animate={{ x: 100 }}
      transition={{ duration: reducedMotion ? 0 : 0.5 }}
    />
  );
}

Testing React accessibility

jest-axe setup

import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

it('should be accessible', async () => {
  const { container } = render(<MyComponent />);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

Testing focus management

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

it('moves focus to modal when opened', async () => {
  render(<App />);
  
  await userEvent.click(screen.getByText('Open Modal'));
  
  expect(screen.getByRole('dialog')).toHaveFocus();
});

it('returns focus when modal closes', async () => {
  render(<App />);
  const openButton = screen.getByText('Open Modal');
  
  await userEvent.click(openButton);
  await userEvent.click(screen.getByText('Close'));
  
  expect(openButton).toHaveFocus();
});

Accessible component libraries

Don't reinvent the wheel. These libraries are built with accessibility in mind:

LibraryNotes
React AriaUnstyled hooks, excellent accessibility. From Adobe.
Radix UIUnstyled primitives, great for design systems.
Headless UIUnstyled components, works with Tailwind.
Chakra UIStyled components, built on accessibility.
Reach UIAccessible foundation components.

React accessibility checklist

  • check_box_outline_blank Semantic HTML elements used
  • check_box_outline_blank All images have alt text
  • check_box_outline_blank Forms have proper labels and error handling
  • check_box_outline_blank Focus management on route changes
  • check_box_outline_blank Modal/dialog focus trapping works
  • check_box_outline_blank Dynamic content announced via live regions
  • check_box_outline_blank Color contrast meets WCAG AA
  • check_box_outline_blank Keyboard navigation works throughout
  • check_box_outline_blank Skip link to main content
  • check_box_outline_blank Page title updates on route change
  • check_box_outline_blank Respects prefers-reduced-motion
  • check_box_outline_blank eslint-plugin-jsx-a11y enabled
  • check_box_outline_blank jest-axe tests for components

Resources