ReactAccessibility (a11y) in React

Accessibility (a11y) in React

Accessibility (abbreviated a11y — 11 letters between the "a" and "y") means building interfaces that work for everyone, including people who use screen readers, keyboard-only navigation, voice control, or other assistive technologies. React gives you full control of the DOM, which means accessibility is entirely your responsibility — the framework won't enforce it for you.

The good news: most accessibility problems are caused by a handful of common patterns, and fixing them doesn't require deep specialised knowledge. This page walks through the most impactful changes you can make to a React application.

Semantic HTML First

The single highest-leverage accessibility improvement is using the correct HTML element. Semantic elements carry built-in meaning, keyboard behaviour, and ARIA roles that screen readers understand:

TSX
// ❌ Non-semantic — screen reader announces a generic "group", no keyboard access
function BadNavigation() {
  return (
    <div onClick={handleNav} className="nav">
      <div className="nav-item">Home</div>
      <div className="nav-item">About</div>
    </div>
  )
}

// ✅ Semantic — screen reader announces "navigation", links are focusable
function GoodNavigation() {
  return (
    <nav aria-label="Main navigation">
      <ul>
        <li><a href="/">Home</a></li>
        <li><a href="/about">About</a></li>
      </ul>
    </nav>
  )
}
  • Use <button> for clickable actions, <a> for navigation — they are keyboard focusable by default

  • Use <nav>, <main>, <aside>, <header>, <footer> landmark elements — screen readers expose these for quick page navigation

  • Use <h1><h6> heading hierarchy — screen reader users navigate by headings to scan page structure

  • Use <ul>/<ol> for lists — screen readers announce item counts

  • Use <table> with <th scope="col"> for tabular data, not div grids

ARIA Attributes

ARIA (Accessible Rich Internet Applications) attributes fill gaps where native HTML semantics aren't enough — custom widgets, dynamic content, and complex interactions:

TSX
// aria-label — provides a text label when visible text isn't present
<button aria-label="Close dialog">✕</button>

// aria-describedby — associates a description with an input
<label htmlFor="password">Password</label>
<input
  id="password"
  type="password"
  aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 8 characters with a number.</p>

// aria-expanded — communicates open/closed state of a collapsible section
function Accordion({ title, children }: Props) {
  const [isOpen, setIsOpen] = useState(false)
  return (
    <div>
      <button
        aria-expanded={isOpen}
        aria-controls="accordion-content"
        onClick={() => setIsOpen((o) => !o)}
      >
        {title}
      </button>
      <div id="accordion-content" hidden={!isOpen}>
        {children}
      </div>
    </div>
  )
}

// aria-hidden — hide decorative elements from screen readers
<span aria-hidden="true">★★★★☆</span>
<span className="sr-only">4 out of 5 stars</span>
Live Regions for Dynamic Content

When content updates dynamically — toast notifications, form validation errors, live search results — screen readers won't announce the update unless you tell them to via aria-live:

TSX
// ❌ Screen reader users miss this notification
function ToastBad({ message }: { message: string }) {
  return <div className="toast">{message}</div>
}

// ✅ aria-live="polite" announces after the current speech finishes
function Toast({ message }: { message: string }) {
  return (
    <div
      role="status"
      aria-live="polite"
      aria-atomic="true"
      className="toast"
    >
      {message}
    </div>
  )
}

// For urgent alerts (e.g. "Your session expired"), use aria-live="assertive"
function ErrorAlert({ error }: { error: string }) {
  return (
    <div role="alert" aria-live="assertive">
      {error}
    </div>
  )
}
Note
Keep one permanent `aria-live` region in the DOM (e.g. in your root layout) and update its text content rather than mounting/unmounting the element. Some screen readers miss announcements when the live region itself is newly inserted.
Focus Management

When you open a modal dialog, focus should move inside it. When the modal closes, focus should return to the element that triggered it. Without this, keyboard users lose their place entirely:

TSX
'use client'
import { useEffect, useRef } from 'react'

interface DialogProps {
  isOpen: boolean
  onClose: () => void
  children: React.ReactNode
  triggerRef: React.RefObject<HTMLButtonElement>
}

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

  useEffect(() => {
    if (isOpen) {
      // Move focus into the dialog when it opens
      dialogRef.current?.focus()
    } else {
      // Return focus to the trigger when the dialog closes
      triggerRef.current?.focus()
    }
  }, [isOpen, triggerRef])

  if (!isOpen) return null

  return (
    <div
      role="dialog"
      aria-modal="true"
      aria-labelledby="dialog-title"
      ref={dialogRef}
      tabIndex={-1}    // makes the div focusable programmatically
      onKeyDown={(e) => e.key === 'Escape' && onClose()}
    >
      <h2 id="dialog-title">Confirm action</h2>
      {children}
      <button onClick={onClose}>Cancel</button>
    </div>
  )
}
Keyboard Navigation

All interactive elements must be reachable and operable with the keyboard alone. Custom widgets (tab panels, dropdowns, sliders) need to implement the correct keyboard patterns defined in the ARIA Authoring Practices Guide:

TSX
'use client'
import { useState, useRef } from 'react'

const TABS = ['Overview', 'Reviews', 'Specifications']

export function TabList() {
  const [activeIndex, setActiveIndex] = useState(0)
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([])

  function handleKeyDown(e: React.KeyboardEvent, index: number) {
    let nextIndex = index
    if (e.key === 'ArrowRight') nextIndex = (index + 1) % TABS.length
    if (e.key === 'ArrowLeft') nextIndex = (index - 1 + TABS.length) % TABS.length
    if (e.key === 'Home') nextIndex = 0
    if (e.key === 'End') nextIndex = TABS.length - 1

    if (nextIndex !== index) {
      setActiveIndex(nextIndex)
      tabRefs.current[nextIndex]?.focus()
    }
  }

  return (
    <div>
      <div role="tablist" aria-label="Product details">
        {TABS.map((tab, i) => (
          <button
            key={tab}
            role="tab"
            aria-selected={i === activeIndex}
            aria-controls={`panel-${i}`}
            id={`tab-${i}`}
            tabIndex={i === activeIndex ? 0 : -1}
            ref={(el) => { tabRefs.current[i] = el }}
            onClick={() => setActiveIndex(i)}
            onKeyDown={(e) => handleKeyDown(e, i)}
          >
            {tab}
          </button>
        ))}
      </div>
      {TABS.map((tab, i) => (
        <div
          key={tab}
          role="tabpanel"
          id={`panel-${i}`}
          aria-labelledby={`tab-${i}`}
          hidden={i !== activeIndex}
        >
          Content for {tab}
        </div>
      ))}
    </div>
  )
}
Skip Navigation Link

Keyboard users must tab through the entire navigation on every page unless you provide a skip link that jumps to the main content:

TSX
// components/SkipNav.tsx
export function SkipNav() {
  return (
    <a
      href="#main-content"
      className="skip-nav"
    >
      Skip to main content
    </a>
  )
}

// Corresponding CSS — visually hidden until focused
// .skip-nav {
//   position: absolute;
//   top: -100%;
//   left: 0;
//   background: #000;
//   color: #fff;
//   padding: 8px 16px;
//   z-index: 9999;
// }
// .skip-nav:focus { top: 0; }

// In your layout
export function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <SkipNav />
      <Header />
      <main id="main-content" tabIndex={-1}>
        {children}
      </main>
    </>
  )
}
Form Label Association

Every form input needs an associated label. Screen readers read the label when the input receives focus. Placeholder text is not a substitute — it disappears when the user types:

TSX
// ❌ Placeholder only — screen readers may not announce it consistently
<input type="email" placeholder="Email address" />

// ✅ Explicit label association via htmlFor + id
<label htmlFor="email">Email address</label>
<input type="email" id="email" name="email" autoComplete="email" />

// ✅ Implicit association (label wraps input)
<label>
  Email address
  <input type="email" name="email" autoComplete="email" />
</label>

// ✅ Visually hidden label when design requires placeholder-style UI
<label htmlFor="search" className="sr-only">Search posts</label>
<input type="search" id="search" placeholder="Search posts…" />
Image Alt Text

TSX
// ❌ No alt — screen reader reads the filename or nothing
<img src="/hero.jpg" />

// ✅ Descriptive alt for meaningful images
<img src="/hero.jpg" alt="Two developers pair programming at a standing desk" />

// ✅ Empty alt for purely decorative images (screen reader skips it)
<img src="/decorative-wave.svg" alt="" role="presentation" />
Color Contrast

WCAG 2.1 AA requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (>18pt or >14pt bold). Never rely on color alone to convey information:

TSX
// ❌ Color alone indicates status — invisible to colorblind users
<span style={{ color: 'red' }}>Error</span>
<span style={{ color: 'green' }}>Success</span>

// ✅ Icon + color + text — three redundant signals
<span style={{ color: '#c0392b' }}>
  ✕ Error: invalid email address
</span>
<span style={{ color: '#27ae60' }}>
  ✓ Success: profile saved
</span>
Testing Tools
  • axe-core — install @axe-core/react for runtime accessibility auditing in development; errors appear in the console

  • eslint-plugin-jsx-a11y — catches common a11y mistakes at write time (missing alt, incorrect role, etc.)

  • Lighthouse — Chrome DevTools built-in; run an Accessibility audit for a scored report

  • Screen readers — VoiceOver (macOS/iOS), NVDA (Windows, free), TalkBack (Android). Manual testing is irreplaceable

  • React Testing Library — encourages querying by accessible role and label, which naturally promotes accessible code

Warning
Do not assume automated tools catch all accessibility problems. Automated tools find roughly 30–40% of real-world issues. Manual testing with a screen reader and keyboard-only navigation is essential before shipping.
Quick wins
Start with: 1) Add `eslint-plugin-jsx-a11y` to your ESLint config. 2) Run a Lighthouse audit and fix all red items. 3) Tab through your most important user flows with a keyboard — if you get stuck, something is broken.