Accessibility (a11y) in React

Difficulty: Intermediate

Question

How do you implement accessibility in React applications?

Answer

Accessibility, or a11y (because there are 11 letters between the a and y), is one of those topics that separates thoughtful developers from everyone else. It is not just about compliance or checking boxes -- it is about making sure real people with real disabilities can actually use what you build. Let me walk through how to do it well in React.

The foundation of accessibility is semantic HTML, and this is where most developers go wrong from day one. When you need a button, use a button element, not a div with an onClick handler. When you have navigation, wrap it in a nav element. When you have a main content area, use main. An article? Use article. A form input needs a label? Use the label element with htmlFor (React's version of the HTML for attribute) pointing to the input's id.

Why does this matter so much? Because screen readers, keyboard navigation, and other assistive technologies rely on the semantic meaning of HTML elements. A button element is automatically focusable, responds to Enter and Space keys, and is announced as a button by screen readers. A div with an onClick does none of that. You would have to manually add tabindex, role="button", onKeyDown handling for Enter and Space, and aria attributes. That is a lot of work to poorly recreate what the browser gives you for free with a button element.

ARIA attributes (Accessible Rich Internet Applications) are your next tool, but they are a supplement to semantic HTML, not a replacement. The first rule of ARIA is literally: do not use ARIA if you can use a native HTML element instead. ARIA is for when you are building custom interactive widgets that do not have HTML equivalents -- like a custom dropdown, a tabbed interface, a tree view, or a date picker.

The most important ARIA attributes you will use in React: aria-label provides a text label for elements that do not have visible text (like icon buttons -- a trash can icon button needs aria-label="Delete item" so screen readers know what it does). aria-describedby links an element to additional descriptive text, like form field hints or error messages. aria-live tells screen readers to announce dynamic content changes -- for example, when a success message or error message appears after form submission. aria-expanded indicates whether a collapsible section is open or closed. role defines what an element is when you cannot use the native HTML element.

Focus management is a huge topic and the one most React developers get wrong. The rule is: when you programmatically change what is visible on screen, you need to manage where focus goes. The classic example is modals. When a modal opens, focus must move into the modal (usually to the first focusable element or the close button). When the modal closes, focus must return to the element that triggered the modal. If you do not do this, a screen reader user opens your modal and has no idea it opened -- their focus is still behind the modal overlay, trapped.

Focus trapping is related: while a modal is open, pressing Tab should cycle through only the focusable elements inside the modal, not escape to the page behind it. Implementing this from scratch is tricky, which is why libraries like Radix UI and Headless UI are so valuable -- they handle focus trapping, keyboard navigation, and ARIA attributes for you.

Keyboard navigation is another critical piece. Every interactive element must be usable with just the keyboard. Users should be able to Tab through interactive elements, use Enter or Space to activate buttons, use arrow keys in custom menus and tabs, and use Escape to close modals and dropdowns. If you build a custom dropdown with mouse-only interactions, keyboard users (and many power users) cannot use it.

Color contrast matters more than most developers think. WCAG AA requires at least 4.5:1 contrast ratio for normal text and 3:1 for large text. Tools like the Chrome DevTools contrast checker, axe DevTools, or online contrast checkers make this easy to verify. Do not rely on color alone to convey information -- a red error border means nothing to a colorblind user without an error message.

For React specifically, there are some tooling wins. eslint-plugin-jsx-a11y catches common accessibility issues at lint time -- things like missing alt text on images, missing labels on form inputs, and click handlers on non-interactive elements. It is one of those tools that costs nothing to set up and catches real bugs. React's own JSX handles ARIA attributes natively -- you can use aria-label, aria-describedby, aria-live, and all other aria-* attributes directly on JSX elements.

In a production application, I would strongly recommend using a headless component library like Radix UI or Headless UI for complex interactive widgets. Building an accessible dropdown menu, combobox, or dialog from scratch is genuinely hard and easy to get wrong. These libraries handle the ARIA attributes, keyboard navigation, focus management, and screen reader announcements for you, while letting you fully control the styling.

One final best practice: test with actual assistive technology. Turn on VoiceOver (Mac) or NVDA (Windows) and try to use your app without a mouse. You will be surprised how many things break. Automated tools like axe catch maybe 30-40% of accessibility issues. The rest require manual testing with real assistive technology.

Code examples

Accessible Modal with Focus Management

import { useEffect, useRef } from 'react';

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

  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement;
      closeButtonRef.current?.focus(); // Move focus into modal
    } else {
      previousFocusRef.current?.focus(); // Restore previous focus
    }
  }, [isOpen]);

  // Trap focus inside modal
  function handleKeyDown(e) {
    if (e.key === 'Escape') onClose();
  }

  if (!isOpen) return null;

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      onKeyDown={handleKeyDown}
    >
      <div role="document">
        <h2 id="modal-title">{title}</h2>
        {children}
        <button
          ref={closeButtonRef}
          onClick={onClose}
          aria-label="Close dialog"
        >
          ×
        </button>
      </div>
    </div>
  );
}

Focus must move into the modal on open and return to the trigger on close. role=dialog and aria-modal=true announce the modal to screen readers.

Accessible Form and Live Regions

function AccessibleForm() {
  const [error, setError] = useState('');
  const [success, setSuccess] = useState(false);

  return (
    <form onSubmit={handleSubmit} noValidate>
      {/* aria-live announces changes to screen readers */}
      <div aria-live="polite" aria-atomic="true">
        {error && <p role="alert">{error}</p>}
        {success && <p>Form submitted successfully!</p>}
      </div>

      <div>
        {/* htmlFor links label to input */}
        <label htmlFor="email">Email address</label>
        <input
          id="email"
          type="email"
          required
          aria-describedby="email-hint email-error"
          aria-invalid={!!error}
        />
        <span id="email-hint">We will never share your email</span>
        {error && <span id="email-error" role="alert">{error}</span>}
      </div>

      {/* Icon buttons need aria-label */}
      <button type="button" aria-label="Delete item">
        <TrashIcon />
      </button>

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

aria-live announces dynamic content changes. aria-describedby links related text. role=alert for errors. htmlFor is React's equivalent of HTML for attribute.

Key points

Concepts covered

ARIA, Semantic HTML, Focus Management, Screen Readers, Keyboard Navigation