React Accessibility: ARIA, Focus Management and Screen Readers (2026)

Accessibility is not an optional feature — it is a quality requirement. In React, the SPA model introduces unique challenges: dynamic content updates aren't announced to screen readers, focus disappears when modals close, and custom interactive elements lack native semantics. This guide covers the patterns that make React apps usable for everyone.

Semantic HTML First

The most effective accessibility fix is using the right HTML element. Native elements come with built-in keyboard handling, ARIA roles and screen reader announcements for free:

// Bad — div soup, no semantics
<div onClick={handleClick} className="btn">Submit</div>
<div onClick={handleNav} className="link">Go to docs</div>
<div className="heading">Section Title</div>

// Good — native elements
<button type="submit" onClick={handleClick}>Submit</button>
<a href="/docs">Go to docs</a>
<h2>Section Title</h2>

// If you MUST use a div as a button (avoid this)
<div
  role="button"
  tabIndex={0}
  onClick={handleClick}
  onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && handleClick()}
>
  Click me
</div>

ARIA Roles and Attributes

// aria-label — for elements with no visible text
<button aria-label="Close dialog">
  <XIcon aria-hidden="true" />   {/* Hide decorative icon from SR */}
</button>

// aria-labelledby — link label to another element's text
<h2 id="billing-heading">Billing Information</h2>
<form aria-labelledby="billing-heading">...</form>

// aria-describedby — supplementary description
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 12 characters with a symbol.</p>

// aria-expanded — communicates open/closed state
<button
  aria-expanded={isOpen}
  aria-controls="dropdown-menu"
  onClick={() => setIsOpen(!isOpen)}
>
  Options
</button>
<ul id="dropdown-menu" hidden={!isOpen}>...</ul>

// aria-current — marks current item in navigation
<nav>
  {navItems.map(item => (
    <a
      key={item.href}
      href={item.href}
      aria-current={pathname === item.href ? 'page' : undefined}
    >
      {item.label}
    </a>
  ))}
</nav>

// aria-busy — loading state
<div aria-busy={isLoading} aria-live="polite">
  {isLoading ? <Spinner /> : <DataTable data={data} />}
</div>

Focus Management

React SPAs must manage focus manually when content changes — otherwise keyboard users lose their place:

import { useRef, useEffect } from 'react'

// Move focus to heading after route change
function PageLayout({ title, children }) {
  const headingRef = useRef<HTMLHeadingElement>(null)
  const pathname = usePathname()

  useEffect(() => {
    headingRef.current?.focus()
  }, [pathname])   // Focus heading on every navigation

  return (
    <main>
      <h1 ref={headingRef} tabIndex={-1} style={{ outline: 'none' }}>
        {title}
      </h1>
      {children}
    </main>
  )
}

// Restore focus when a panel closes
function useRestoreFocus(isOpen: boolean) {
  const triggerRef = useRef<HTMLElement | null>(null)

  useEffect(() => {
    if (isOpen) {
      // Save the element that opened the panel
      triggerRef.current = document.activeElement as HTMLElement
    } else {
      // Restore focus when panel closes
      triggerRef.current?.focus()
    }
  }, [isOpen])

  return triggerRef
}

// Focus trap — keep focus inside a modal
function useFocusTrap(containerRef: React.RefObject<HTMLElement>, isActive: boolean) {
  useEffect(() => {
    if (!isActive || !containerRef.current) return

    const focusable = containerRef.current.querySelectorAll<HTMLElement>(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const first = focusable[0]
    const last = focusable[focusable.length - 1]

    first?.focus()

    function handleKeyDown(e: KeyboardEvent) {
      if (e.key !== 'Tab') return
      if (e.shiftKey) {
        if (document.activeElement === first) { e.preventDefault(); last?.focus() }
      } else {
        if (document.activeElement === last) { e.preventDefault(); first?.focus() }
      }
    }

    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [isActive, containerRef])
}

Keyboard Navigation

// Arrow-key navigation for a listbox / menu
function Menu({ items, onSelect }) {
  const [activeIndex, setActiveIndex] = useState(0)

  function handleKeyDown(e: React.KeyboardEvent) {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault()
        setActiveIndex(i => Math.min(i + 1, items.length - 1))
        break
      case 'ArrowUp':
        e.preventDefault()
        setActiveIndex(i => Math.max(i - 1, 0))
        break
      case 'Enter':
      case ' ':
        e.preventDefault()
        onSelect(items[activeIndex])
        break
      case 'Escape':
        onClose()
        break
      case 'Home':
        e.preventDefault()
        setActiveIndex(0)
        break
      case 'End':
        e.preventDefault()
        setActiveIndex(items.length - 1)
        break
    }
  }

  return (
    <ul role="listbox" onKeyDown={handleKeyDown}>
      {items.map((item, i) => (
        <li
          key={item.id}
          role="option"
          aria-selected={i === activeIndex}
          tabIndex={i === activeIndex ? 0 : -1}
          onClick={() => onSelect(item)}
        >
          {item.label}
        </li>
      ))}
    </ul>
  )
}

Live Regions

Live regions announce dynamic content updates to screen readers without moving focus:

// Toast notifications — assertive for errors, polite for success
function ToastRegion({ toasts }) {
  return (
    <>
      {/* Polite: SR finishes current sentence before announcing */}
      <div role="status" aria-live="polite" aria-atomic="true" className="sr-only">
        {toasts.filter(t => t.type === 'success').map(t => t.message).join('. ')}
      </div>
      {/* Assertive: interrupts SR immediately — use for errors only */}
      <div role="alert" aria-live="assertive" aria-atomic="true" className="sr-only">
        {toasts.filter(t => t.type === 'error').map(t => t.message).join('. ')}
      </div>
    </>
  )
}

// Search results count
function SearchResults({ query, count }) {
  return (
    <>
      {/* Announces result count when it changes */}
      <div role="status" aria-live="polite" className="sr-only">
        {count} results for "{query}"
      </div>
      <ResultsList />
    </>
  )
}
function AccessibleModal({ isOpen, onClose, title, children }) {
  const modalRef = useRef<HTMLDivElement>(null)
  useRestoreFocus(isOpen)
  useFocusTrap(modalRef, isOpen)

  // Close on Escape
  useEffect(() => {
    const handler = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose() }
    if (isOpen) document.addEventListener('keydown', handler)
    return () => document.removeEventListener('keydown', handler)
  }, [isOpen, onClose])

  if (!isOpen) return null

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
      ref={modalRef}
      className="modal-overlay"
    >
      <div className="modal-content">
        <h2 id="modal-title">{title}</h2>
        {children}
        <button onClick={onClose} aria-label="Close dialog">✕</button>
      </div>
      {/* Click outside to close */}
      <div className="modal-backdrop" onClick={onClose} aria-hidden="true" />
    </div>
  )
}

// Better: use Radix UI Dialog which handles all of this for you
import * as Dialog from '@radix-ui/react-dialog'

<Dialog.Root open={isOpen} onOpenChange={setIsOpen}>
  <Dialog.Trigger asChild><Button>Open</Button></Dialog.Trigger>
  <Dialog.Portal>
    <Dialog.Overlay className="modal-overlay" />
    <Dialog.Content className="modal-content">
      <Dialog.Title>Edit Profile</Dialog.Title>
      <Dialog.Description>Make changes to your profile here.</Dialog.Description>
      <Dialog.Close asChild><Button>Save</Button></Dialog.Close>
    </Dialog.Content>
  </Dialog.Portal>
</Dialog.Root>

Accessible Forms

// Every input needs a visible label — not just placeholder
function FormField({ id, label, error, required, ...props }) {
  return (
    <div>
      <label htmlFor={id}>
        {label}
        {required && <span aria-hidden="true"> *</span>}
        {required && <span className="sr-only"> (required)</span>}
      </label>
      <input
        id={id}
        aria-required={required}
        aria-invalid={!!error}
        aria-describedby={error ? `${id}-error` : undefined}
        {...props}
      />
      {error && (
        <p id={`${id}-error`} role="alert" className="error-text">
          {error}
        </p>
      )}
    </div>
  )
}

Testing with axe-core

npm install --save-dev @axe-core/react axe-core jest-axe

// In development — log violations to console
import React from 'react'
if (process.env.NODE_ENV !== 'production') {
  const axe = require('@axe-core/react')
  axe(React, ReactDOM, 1000)
}

// In jest tests
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)

test('Button has no accessibility violations', async () => {
  const { container } = render(<Button>Submit</Button>)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})