DEV Community

Atlas Whoff
Atlas Whoff

Posted on

Web Accessibility in React: Semantic HTML, ARIA, Focus Management, and axe Testing

Accessibility isn't a feature you add at the end — it's a quality signal that correlates with better code overall. Well-structured, accessible apps tend to have better semantics, better keyboard behavior, and better performance. Here's the practical implementation.

Semantic HTML First

Every accessibility problem that seems hard is often just a semantic HTML problem:

// Bad -- div soup, no semantics
<div className='header'>
  <div className='nav'>
    <div onClick={handleClick}>Home</div>
    <div onClick={handleClick}>About</div>
  </div>
</div>

// Good -- semantic HTML, keyboard accessible automatically
<header>
  <nav aria-label='Main navigation'>
    <ul>
      <li><a href='/'>Home</a></li>
      <li><a href='/about'>About</a></li>
    </ul>
  </nav>
</header>
Enter fullscreen mode Exit fullscreen mode

Landmark elements (<header>, <nav>, <main>, <aside>, <footer>) give screen reader users a way to jump between sections.

ARIA: When and When Not To

ARIA attributes add semantic meaning when HTML alone isn't enough. But they don't add behavior — you must implement keyboard interaction yourself:

// Custom dropdown -- requires ARIA + keyboard handling
function Dropdown({ label, options, value, onChange }) {
  const [isOpen, setIsOpen] = useState(false)
  const listboxId = useId()

  return (
    <div>
      <button
        aria-haspopup='listbox'
        aria-expanded={isOpen}
        aria-controls={listboxId}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={(e) => {
          if (e.key === 'Escape') setIsOpen(false)
          if (e.key === 'ArrowDown') { setIsOpen(true); /* focus first option */ }
        }}
      >
        {value || label}
      </button>
      {isOpen && (
        <ul id={listboxId} role='listbox' aria-label={label}>
          {options.map(opt => (
            <li
              key={opt.value}
              role='option'
              aria-selected={value === opt.value}
              onClick={() => { onChange(opt.value); setIsOpen(false) }}
              tabIndex={0}
            >
              {opt.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

The rule: if a native HTML element does what you need (<select>, <button>, <a>), use it. ARIA is for custom widgets.

Focus Management

// Dialog -- trap focus inside when open
import { useEffect, useRef } from 'react'

function Dialog({ isOpen, onClose, children }) {
  const dialogRef = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!isOpen) return

    // Focus the dialog on open
    dialogRef.current?.focus()

    // Trap focus
    const focusable = dialogRef.current?.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )
    const first = focusable?.[0] as HTMLElement
    const last = focusable?.[focusable.length - 1] as HTMLElement

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

    document.addEventListener('keydown', trap)
    return () => document.removeEventListener('keydown', trap)
  }, [isOpen])

  return (
    <div
      ref={dialogRef}
      role='dialog'
      aria-modal='true'
      tabIndex={-1}
      onKeyDown={(e) => e.key === 'Escape' && onClose()}
    >
      {children}
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Color Contrast and Visual Design

WCAG AA requires 4.5:1 contrast for normal text, 3:1 for large text:

// Check contrast in your Tailwind config
// Gray-500 on white: 3.95:1 -- FAILS AA for normal text
// Gray-700 on white: 8.59:1 -- passes AAA

// Don't rely on color alone to convey information
// Bad: red = error (invisible to colorblind users)
// Good: red + icon + error message text
<div className='flex items-center gap-2 text-red-600'>
  <AlertCircle className='w-4 h-4' aria-hidden='true' />
  <span role='alert'>Email is invalid</span>
</div>
Enter fullscreen mode Exit fullscreen mode

Automated Testing with axe

npm install -D @axe-core/react jest-axe
Enter fullscreen mode Exit fullscreen mode
import { axe, toHaveNoViolations } from 'jest-axe'
expect.extend(toHaveNoViolations)

it('has no accessibility violations', async () => {
  const { container } = render(<LoginForm />)
  const results = await axe(container)
  expect(results).toHaveNoViolations()
})
Enter fullscreen mode Exit fullscreen mode

Run axe in CI. Catch regressions before they reach users.


The AI SaaS Starter at whoffagents.com ships with semantic HTML structure, proper ARIA labels on all interactive elements, and axe-core integrated into the test suite. $99 one-time.

Top comments (0)