Technology · React

React Accessibility

Build accessible React apps using semantic HTML, ARIA attributes, and keyboard navigation.

TL;DR
  1. 01Use semantic HTML elements to give the browser meaningful structure.
  2. 02Add ARIA attributes only when semantic HTML is not enough.
  3. 03Test every interactive element with keyboard and screen readers.

Semantic HTML

  • Use semantic elements instead of divs.
    // Bad: divs with no meaning
    <div onClick={() => setOpen(!open)}>Menu</div>
    
    // Good: semantic button element
    <button onClick={() => setOpen(!open)}>Menu</button>
  • Use proper heading hierarchy to structure pages.
    <h1>Main Title</h1>
    <section>
      <h2>Section Title</h2>
      <p>Content</p>
    </section>
  • Use landmark elements for page regions.
    <header>Site Header</header>
    <nav>Navigation Links</nav>
    <main>Page Content</main>
    <footer>Site Footer</footer>
  • Use lists for groups of related items.
    <ul>
      <li><a href="/about">About</a></li>
      <li><a href="/contact">Contact</a></li>
    </ul>
  • Use the button element for any clickable action.
    // Button triggers an action — use <button>
    <button onClick={openModal}>Open Settings</button>
    
    // Link navigates — use <a>
    <a href="/profile">View Profile</a>

ARIA Attributes

  • Use aria-label when the visible text doesn't describe the element.
    <button aria-label="Close menu">×</button>
    <button aria-label="Delete item Alice">Delete</button>
  • Use aria-live to announce dynamic content to screen readers.
    <div aria-live="polite" aria-atomic="true">
      {statusMessage}
    </div>
  • Use role to define the purpose of a custom element.
    <div role="button" onClick={handleClick} tabIndex="0">
      Custom Button
    </div>
  • Use aria-expanded to indicate open or closed state.
    <button
      aria-expanded={isOpen}
      aria-controls="menu-list"
      onClick={() => setIsOpen(!isOpen)}
    >
      Menu
    </button>
  • Use aria-describedby to link help text to an input.
    <input
      id="password"
      type="password"
      aria-describedby="password-hint"
    />
    <p id="password-hint">Must be at least 8 characters.</p>

Forms and Labels

  • Link every input to a label using htmlFor and id.
    <label htmlFor="email">Email:</label>
    <input id="email" type="email" />
  • Use specific input types for built-in validation.
    <input type="email" />
    <input type="password" />
    <input type="date" />
  • Show validation errors with aria-invalid and aria-errormessage.
    <input
      id="email"
      type="email"
      aria-invalid={!!error}
      aria-errormessage="email-error"
    />
    {error && <p id="email-error" role="alert">{error}</p>}
  • Mark required fields with required and aria-required.
    <label htmlFor="name">Name <span aria-hidden="true">*</span></label>
    <input id="name" type="text" required aria-required="true" />
  • Use fieldset and legend to group related form controls.
    <fieldset>
      <legend>Notification preferences</legend>
      <label><input type="checkbox" name="email" /> Email</label>
      <label><input type="checkbox" name="sms" /> SMS</label>
    </fieldset>

Keyboard Navigation

  • Make interactive elements keyboard accessible by default.
    // Buttons and links are keyboard accessible by default
    <button onClick={handleClick}>Click me</button>
    <a href="/next">Next page</a>
  • Use tabIndex="0" to make custom elements focusable.
    <div
      role="button"
      tabIndex="0"
      onClick={handleClick}
      onKeyDown={(e) => e.key === "Enter" && handleClick()}
    >
      Custom Button
    </div>
  • Handle both Enter and Space for custom button elements.
    function handleKeyDown(e) {
      if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        handleClick();
      }
    }
  • Trap focus inside modals when they are open.
    function Modal({ isOpen, onClose, children }) {
      const firstFocusRef = useRef(null);
      
      useEffect(() => {
        if (isOpen) firstFocusRef.current?.focus();
      }, [isOpen]);
      
      return isOpen ? (
        <div role="dialog" aria-modal="true">
          <button ref={firstFocusRef} onClick={onClose}>Close</button>
          {children}
        </div>
      ) : null;
    }
  • Use tabIndex="-1" to remove elements from tab order.
    // Remove decorative icon from keyboard navigation
    <span tabIndex="-1" aria-hidden="true">★</span>

Screen Reader Testing

  • Test with built-in screen readers on each platform.
    macOS: VoiceOver (Cmd + F5)
    Windows: NVDA (free download at nvaccess.org)
    iOS: VoiceOver (Settings > Accessibility)
    Android: TalkBack (Settings > Accessibility)
  • Use aria-live for dynamic status messages.
    <div aria-live="polite" aria-atomic="true">
      {loadingMessage}
    </div>
  • Use role="alert" for urgent messages that need immediate attention.
    {error && (
      <div role="alert">
        {error}
      </div>
    )}
  • Hide decorative elements from screen readers with aria-hidden.
    <img src="decorative-bg.png" alt="" aria-hidden="true" />
    <span aria-hidden="true"></span>
  • Use jest-axe to automate accessibility checks in tests.
    import { axe, toHaveNoViolations } from 'jest-axe';
    expect.extend(toHaveNoViolations);
    
    test('has no accessibility violations', async () => {
      const { container } = render(<MyForm />);
      const results = await axe(container);
      expect(results).toHaveNoViolations();
    });

Tip: Use semantic HTML first — it covers 80% of accessibility requirements without any extra ARIA attributes or JavaScript.

Warning: ARIA is not a substitute for semantic HTML — use it to enhance, not replace proper HTML structure.

React useReducer HookReact Component Patterns