DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Accessible React Components: ARIA Patterns, Focus Management, and Screen Reader Testing

Accessible React Components: ARIA Patterns, Focus Management, and Screen Reader Testing

Accessibility isn't just compliance. It's about building apps that work for everyone.
Here are the patterns that matter most in production React apps.

Semantic HTML First

// Bad — div soup, screen reader can't understand structure
<div onClick={submitForm}>
  <div>Submit</div>
</div>

// Good — semantic elements communicate purpose
<button type="submit" onClick={submitForm}>
  Submit
</button>
Enter fullscreen mode Exit fullscreen mode

Use <button> for actions, <a> for navigation, <nav> for navigation regions,
<main> for page content, <h1>-<h6> for hierarchy.

ARIA Labels

// Icon-only button needs label
<button aria-label="Close dialog">
  <XIcon aria-hidden="true" />  {/* hide decorative icon from SR */}
</button>

// Associate label with input
<label htmlFor="email">Email address</label>
<input id="email" type="email" />

// Or use aria-label directly
<input
  type="search"
  aria-label="Search products"
  placeholder="Search..."
/>

// Describe complex elements
<div
  role="status"
  aria-live="polite"
  aria-label="Cart total"
>
  ${cartTotal}
</div>
Enter fullscreen mode Exit fullscreen mode

Modal Focus Management

import { useEffect, useRef } from 'react'

function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const closeButtonRef = useRef<HTMLButtonElement>(null)

  useEffect(() => {
    if (isOpen) {
      // Move focus into modal
      closeButtonRef.current?.focus()
    }
  }, [isOpen])

  if (!isOpen) return null

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="modal-title"
    >
      <h2 id="modal-title">{title}</h2>
      {children}
      <button ref={closeButtonRef} onClick={onClose}>
        Close
      </button>
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Focus Trap

// Trap focus inside modal (keyboard users can't tab out)
function useFocusTrap(ref: React.RefObject<HTMLElement>, active: boolean) {
  useEffect(() => {
    if (!active || !ref.current) return

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

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

    document.addEventListener('keydown', handleKeyDown)
    return () => document.removeEventListener('keydown', handleKeyDown)
  }, [active, ref])
}
Enter fullscreen mode Exit fullscreen mode

Live Regions

// Announce dynamic changes to screen readers
function SaveButton({ onSave }: { onSave: () => Promise<void> }) {
  const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')

  const handleSave = async () => {
    setStatus('saving')
    try {
      await onSave()
      setStatus('saved')
      setTimeout(() => setStatus('idle'), 2000)
    } catch {
      setStatus('error')
    }
  }

  return (
    <>
      <button onClick={handleSave} disabled={status === 'saving'}>
        {status === 'saving' ? 'Saving...' : 'Save'}
      </button>
      {/* Screen readers announce this change */}
      <div role="status" aria-live="polite" className="sr-only">
        {status === 'saved' && 'Changes saved successfully'}
        {status === 'error' && 'Failed to save changes'}
      </div>
    </>
  )
}
Enter fullscreen mode Exit fullscreen mode

Testing with axe-core

npm install -D @axe-core/react
Enter fullscreen mode Exit fullscreen mode
// In development only
if (process.env.NODE_ENV !== 'production') {
  const axe = await import('@axe-core/react')
  axe.default(React, ReactDOM, 1000)
}
Enter fullscreen mode Exit fullscreen mode

Logs accessibility violations to the browser console in development.

Tailwind Screen Reader Class

// Visually hidden but announced by screen readers
<span className="sr-only">Current page:</span>
<span>Dashboard</span>

// Skip to main content link (keyboard users)
<a
  href="#main"
  className="sr-only focus:not-sr-only focus:fixed focus:top-4 focus:left-4 focus:z-50"
>
  Skip to main content
</a>
Enter fullscreen mode Exit fullscreen mode

The AI SaaS Starter Kit ships with semantic HTML, ARIA labels, and focus management patterns built in. $99 one-time.

Top comments (0)