React Accessibility: Building Accessible React Applications

accessible developmentframework accessibilityReact accessibilityARIABrowseCheck
·5 min read

React is one of the most popular JavaScript frameworks, but its component-based architecture and client-side rendering create unique accessibility challenges. This guide covers React-specific accessibility patterns, common pitfalls, and best practices for building WCAG-compliant React applications.

React Accessibility Challenges

Client-side routing: Page changes don't announce to screen readers

Focus management: Focus lost during component updates

Dynamic content: Updates not announced to assistive technology

JSX limitations: Some HTML patterns require workarounds

ARIA in JSX: Slightly different syntax than HTML

JSX and ARIA Attributes

Standard ARIA

HTML ARIA attributes work in JSX with one exception:

// aria-label, aria-labelledby, aria-describedby work as-is
<button aria-label="Close dialog">×</button>

// aria-* attributes use camelCase in JSX? No, use lowercase
<div aria-labelledby="title">  {/* Correct */}
<div ariaLabelledby="title">  {/* Wrong */}

Important: ARIA attributes stay lowercase in JSX (unlike other React props).

data-* Attributes

<button data-toggle="modal">Open</button>

Lowercase, like ARIA.

className vs class

<div className="container">  {/* React */}
<div class="container">      {/* HTML */}

htmlFor vs for

<label htmlFor="email">Email</label>  {/* React */}
<label for="email">Email</label>      {/* HTML */}

##Focus Management in React

Auto-focus on Mount

import { useEffect, useRef } from 'react';

function Dialog() {
  const closeButtonRef = useRef(null);

  useEffect(() => {
    closeButtonRef.current?.focus();
  }, []);

  return (
    <div role="dialog">
      <button ref={closeButtonRef}>Close</button>
    </div>
  );
}

Return Focus After Unmount

function Modal({ onClose }) {
  const previouslyFocusedRef = useRef(null);

  useEffect(() => {
    previouslyFocusedRef.current = document.activeElement;

    return () => {
      previouslyFocusedRef.current?.focus();
    };
  }, []);

  return (
    <div role="dialog">
      <button onClick={onClose}>Close</button>
    </div>
  );
}

Client-Side Routing

React Router Focus Management

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

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

  useEffect(() => {
    // Move focus to main content on route change
    const mainContent = document.getElementById('main-content');
    if (mainContent) {
      mainContent.tabIndex = -1;
      mainContent.focus();
      mainContent.removeAttribute('tabindex');
    }

    // Or announce page change
    const announcement = document.getElementById('route-announce');
    if (announcement) {
      announcement.textContent = `Navigated to ${document.title}`;
    }
  }, [location]);

  return <div>...</div>;
}

Route Announcements

<div
  id="route-announce"
  role="status"
  aria-live="polite"
  aria-atomic="true"
  className="visually-hidden"
/>

Dynamic Content Updates

Live Regions

function SearchResults({ results, loading }) {
  return (
    <>
      <div role="status" aria-live="polite" aria-atomic="true">
        {loading ? 'Loading...' : `${results.length} results found`}
      </div>

      <ul>
        {results.map(result => (
          <li key={result.id}>{result.title}</li>
        ))}
      </ul>
    </>
  );
}

Toast Notifications

function Toast({ message }) {
  return (
    <div role="alert" aria-live="assertive">
      {message}
    </div>
  );
}

Form Accessibility

Accessible Form Component

function ContactForm() {
  const [errors, setErrors] = useState({});

  return (
    <form onSubmit={handleSubmit}>
      <div>
        <label htmlFor="email">Email address</label>
        <input
          type="email"
          id="email"
          name="email"
          aria-invalid={errors.email ? 'true' : 'false'}
          aria-describedby={errors.email ? 'email-error' : undefined}
        />
        {errors.email && (
          <span id="email-error" role="alert">
            {errors.email}
          </span>
        )}
      </div>

      <button type="submit">Submit</button>
    </form>
  );
}

Semantic HTML in JSX

Use semantic elements:

// Good
<button onClick={handleClick}>Click me</button>

// Bad
<div onClick={handleClick}>Click me</div>

Use fragments to avoid extra divs:

return (
  <>
    <h1>Title</h1>
    <p>Content</p>
  </>
);

React Accessibility Libraries

react-aria (Adobe)

Provides accessible component hooks:

import { useButton } from 'react-aria';

function Button(props) {
  const ref = useRef();
  const { buttonProps } = useButton(props, ref);

  return <button {...buttonProps} ref={ref}>{props.children}</button>;
}

Reach UI

Pre-built accessible components:

import { Dialog } from '@reach/dialog';

function MyDialog() {
  return (
    <Dialog aria-label="My dialog">
      <p>Content</p>
    </Dialog>
  );
}

Radix UI

Unstyled accessible components:

import * as Dialog from '@radix-ui/react-dialog';

<Dialog.Root>
  <Dialog.Trigger>Open</Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay />
    <Dialog.Content>
      <Dialog.Title>Title</Dialog.Title>
      <Dialog.Description>Description</Dialog.Description>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

ESLint Plugin

eslint-plugin-jsx-a11y

Catches accessibility issues during development:

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

.eslintrc:

{
  "extends": ["plugin:jsx-a11y/recommended"]
}

Catches:

  • Missing alt text
  • Invalid ARIA
  • Missing labels
  • Keyboard accessibility issues

Testing React Accessibility

React Testing Library

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

expect.extend(toHaveNoViolations);

test('Button is accessible', async () => {
  const { container } = render(<Button>Click me</Button>);
  const results = await axe(container);
  expect(results).toHaveNoViolations();
});

test('Button has accessible name', () => {
  render(<Button>Submit</Button>);
  expect(screen.getByRole('button', { name: /submit/i })).toBeInTheDocument();
});

Common Mistakes

onClick on div without keyboard support:

// ❌ Bad
<div onClick={handleClick}>Click</div>

// ✅ Good
<button onClick={handleClick}>Click</button>

Missing key prop in lists:

// ❌ Bad
{items.map(item => <li>{item}</li>)}

// ✅ Good
{items.map(item => <li key={item.id}>{item}</li>)}

No focus management after route change:

// ❌ Bad - focus stays on clicked link

// ✅ Good - move focus to main content

Checklist

  • [ ] Use semantic HTML elements
  • [ ] Manage focus on route changes
  • [ ] Announce dynamic content updates
  • [ ] Use eslint-plugin-jsx-a11y
  • [ ] Test with React Testing Library + axe
  • [ ] Provide keyboard access to all interactive elements
  • [ ] Use ARIA correctly (lowercase in JSX)
  • [ ] Return focus after modal closes
  • [ ] Test with screen reader (NVDA, VoiceOver)

Conclusion

React accessibility requires focus management, route announcements, and proper ARIA usage. Use semantic HTML, manage focus during updates, announce dynamic changes with live regions, and leverage libraries like react-aria or Reach UI.

ESLint plugin catches many issues during development. Test with React Testing Library, axe, and screen readers. Tools like BrowseCheck monitor React applications for ongoing WCAG compliance.

Accessible React apps benefit all users with clearer interaction patterns and better keyboard support.