Skip to content

React Accessibility — Accessible Component Patterns in React Guide

DodaTech Updated 2026-06-24 10 min read

In this tutorial, you'll learn about React Accessibility. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

React Accessibility means building components that manage focus with hooks like useRef and useEffect, apply ARIA attributes conditionally in JSX, render live regions for dynamic content, and maintain keyboard navigation — using React patterns to enforce Accessibility at the component level.

What You'll Learn

By the end of this guide, you'll understand how to manage focus programmatically with React hooks, how to apply ARIA attributes conditionally in JSX, how to build accessible form components with error announcements, how to handle dynamic content with live regions in React, and how to test React component Accessibility with Jest-axe and @testing-library.

Why React Accessibility Matters

React's component model is ideal for Accessibility. A well-built accessible component encapsulates all keyboard handling, ARIA attributes, and focus management — so every instance of that component is automatically accessible. But React also introduces Accessibility pitfalls: dynamic rendering can break focus order, uncontrolled State can leave ARIA attributes stale, and fragments can disrupt heading hierarchy. At DodaTech, Doda Browser uses React components with built-in Accessibility patterns, and Durga Antivirus Pro's dashboard uses React hooks for focus management in scan result panels.

React Accessibility Decision Flow

flowchart TD
  A[React component] --> B[Is it interactive?]
  B -->|Yes| C[Add keyboard handling]
  B -->|No| D[Sematic HTML sufficient]
  C --> E[useRef + useEffect for focus]
  C --> F[onKeyDown handlers]
  C --> G[Conditional ARIA attributes]
  E --> H[Manage focus on mount/update]
  F --> I[Arrow keys, Enter, Escape]
  G --> J[aria-expanded, aria-selected]
  H --> K[Test with jest-axe]
  I --> K
  J --> K

{{< callout type="info" icon="sparkles" >}} Prerequisites: Intermediate React knowledge (hooks, refs, State management). Basic ARIA and WCAG understanding. {{< /callout >}}

Focus Management with Hooks

Managing focus in React requires refs and effects, since React's Virtual Dom does not automatically handle focus when components mount or update:

import { useRef, useEffect } from 'React';

function useFocusOnMount() {
  const ref = useRef(null);

  useEffect(() => {
    if (ref.current) {
      ref.current.focus();
    }
  }, []);

  return ref;
}

function usePreviousFocus() {
  const previousRef = useRef(null);

  useEffect(() => {
    previousRef.current = document.activeElement;
    return () => {
      if (previousRef.current && previousRef.current.focus) {
        previousRef.current.focus();
      }
    };
  }, []);

  return previousRef;
}

function ScanDialog({ isOpen, onClose }) {
  const dialogRef = useFocusOnMount();
  const previousFocus = usePreviousFocus();

  useEffect(() => {
    function handleKeyDown(e) {
      if (e.key === 'Escape' && isOpen) {
        onClose();
      }
    }
    document.addEventListener('keydown', handleKeyDown);
    return () => document.removeEventListener('keydown', handleKeyDown);
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      ref={dialogRef}
      role="dialog"
      aria-modal="true"
      aria-labelledby="scan-title"
      tabIndex={-1}
    >
      <h2 id="scan-title">Quick Scan  Durga Antivirus Pro</h2>
      <button onClick={onClose}>Start Scan</button>
      <button onClick={onClose}>Cancel</button>
    </div>
  );
}

Why this works: useFocusOnMount focuses the dialog when it opens. usePreviousFocus stores the previously focused element and returns focus when the component unmounts (dialog closes). The Escape key handler closes the dialog.

Focus Trap Hook

For modals, you need a focus trap that cycles Tab within the dialog:

function useFocusTrap(isActive) {
  const containerRef = useRef(null);

  useEffect(() => {
    if (!isActive || !containerRef.current) return;

    const container = containerRef.current;
    const focusableSelector = 'a[href], button, textarea, input, select, [tabindex]:not([tabindex="-1"])';

    function handleTab(e) {
      if (e.key !== 'Tab') return;
      const focusable = [...container.querySelectorAll(focusableSelector)]
        .filter(el => el.offsetParent !== null);
      if (focusable.length === 0) return;
      const first = focusable[0];
      const last = focusable[focusable.length - 1];
      if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
      } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
      }
    }

    container.addEventListener('keydown', handleTab);
    return () => container.removeEventListener('keydown', handleTab);
  }, [isActive]);

  return containerRef;
}

function ThreatAlertDialog({ threat, onClose }) {
  const dialogRef = useFocusTrap(true);

  return (
    <div ref={dialogRef} role="alertdialog" aria-modal="true"
         aria-labelledby="alert-title">
      <h2 id="alert-title">Threat Detected</h2>
      <p>Type: {threat.type} | Risk: {threat.risk}</p>
      <button onClick={() => quarantine(threat)}>Quarantine</button>
      <button onClick={onClose}>Dismiss</button>
    </div>
  );
}

Why this works: The useFocusTrap hook attaches a Tab key handler to the container. When focus reaches the last focusable element, it loops to the first. Shift+Tab reverses. This prevents keyboard users from tabbing into the background.

Accessible Accordion Component

Accordions need aria-expanded, aria-controls, and keyboard support:

import { useState, useCallback } from 'React';

function Accordion({ items }) {
  const [openIndex, setOpenIndex] = useState(null);

  return (
    <div>
      {items.map((item, index) => (
        <AccordionPanel
          key={index}
          isOpen={openIndex === index}
          onToggle={() => setOpenIndex(openIndex === index ? null : index)}
          title={item.title}
          content={item.content}
        />
      ))}
    </div>
  );
}

function AccordionPanel({ isOpen, onToggle, title, content }) {
  const panelId = `panel-${title.replace(/\s+/g, '-').toLowerCase()}`;
  const buttonId = `${panelId}-button`;

  const handleKeyDown = useCallback((e) => {
    if (e.key === 'Enter' || e.key === ' ') {
      e.preventDefault();
      onToggle();
    }
  }, [onToggle]);

  return (
    <div>
      <h3>
        <button
          id={buttonId}
          aria-expanded={isOpen}
          aria-controls={panelId}
          onClick={onToggle}
          onKeyDown={handleKeyDown}
        >
          {title}
        </button>
      </h3>
      <div
        id={panelId}
        role="region"
        aria-labelledby={buttonId}
        hidden={!isOpen}
      >
        {content}
      </div>
    </div>
  );
}

Why this works: aria-expanded toggles between true and false. aria-controls links the button to the panel. The panel has role="region" with aria-labelledby pointing to the button. hidden attribute hides/shows the content.

Live Regions for Dynamic Content

React State updates need to be announced to screen readers. Use live regions for dynamic status messages:

import { useState, useEffect } from 'React';

function ScanProgress({ scanId }) {
  const [status, setStatus] = useState('idle');
  const [progress, setProgress] = useState(0);

  useEffect(() => {
    if (scanId) {
      setStatus('scanning');
      const interval = setInterval(() => {
        setProgress(p => {
          if (p >= 100) {
            clearInterval(interval);
            setStatus('complete');
            return 100;
          }
          return p + 10;
        });
      }, 500);
      return () => clearInterval(interval);
    }
  }, [scanId]);

  return (
    <div>
      <div role="progressbar" aria-valuenow={progress}
           aria-valuemin={0} aria-valuemax={100}
           aria-label="Scan progress">
        <div style={{ width: `${progress}%`, height: '20px', background: '#005fcc' }} />
      </div>

      <div aria-live="polite" aria-atomic="true" className="sr-only">
        {status === 'scanning' && `Scanning... ${progress}% complete.`}
        {status === 'complete' && 'Scan complete. No threats detected.'}
      </div>

      {status === 'complete' && (
        <button onClick={() => showResults(scanId)}>View Results</button>
      )}
    </div>
  );
}

Why this works: aria-live="polite" announces status changes without interrupting. aria-atomic="true" ensures the entire message is read. The progress bar has proper ARIA attributes for the visual indicator.

Accessible Form with Error Announcements

React forms need inline errors, error summaries, and live region announcements:

import { useState } from 'React';

function ScanConfigForm({ onSubmit }) {
  const [errors, setErrors] = useState({});

  function validate(data) {
    const errs = {};
    if (!data.targetPath) errs.targetPath = 'Select a file or folder to scan.';
    if (data.scanType === 'custom' && !data.customPattern) {
      errs.customPattern = 'Enter a file pattern for custom scan.';
    }
    return errs;
  }

  function handleSubmit(e) {
    e.preventDefault();
    const formData = new FormData(e.target);
    const data = Object.fromEntries(formData);
    const errs = validate(data);
    setErrors(errs);
    if (Object.keys(errs).length === 0) {
      onSubmit(data);
    }
  }

  return (
    <form onSubmit={handleSubmit} noValidate aria-label="Scan configuration">
      <div role="group" aria-labelledby="scan-type-label">
        <span id="scan-type-label">Scan type</span>
        <label>
          <input type="radio" name="scanType" value="quick" defaultChecked />
          Quick scan
        </label>
        <label>
          <input type="radio" name="scanType" value="full" />
          Full scan
        </label>
        <label>
          <input type="radio" name="scanType" value="custom" />
          Custom scan
        </label>
      </div>

      <div>
        <label htmlFor="targetPath">Target path</label>
        <input id="targetPath" name="targetPath" type="text"
               aria-invalid={!!errors.targetPath}
               aria-describedby={errors.targetPath ? 'targetPath-error' : undefined} />
        {errors.targetPath && (
          <p id="targetPath-error" role="alert">{errors.targetPath}</p>
        )}
      </div>

      <button type="submit">Start Scan</button>

      {Object.keys(errors).length > 0 && (
        <div role="alert" aria-live="polite">
          <p>{Object.keys(errors).length} error(s) found. Please fix them before proceeding.</p>
        </div>
      )}
    </form>
  );
}

Why this works: Each input uses aria-invalid and aria-describedby pointing to the error message. Errors have role="alert" for immediate announcement. A summary alert reports the total error count.

Testing React Accessibility

Use Jest-axe and @testing-library to test components automatically:

import { render, fireEvent } from '@testing-library/React';
import { axe, toHaveNoViolations } from 'Jest-axe';
import { Accordion, ScanDialog, ScanConfigForm } from './components';

expect.extend(toHaveNoViolations);

describe('Accessible components', () => {
  it('Accordion has no violations', async () => {
    const { container } = render(
      <Accordion items={[
        { title: 'What is heuristics?', content: 'Heuristic analysis...' },
        { title: 'What is sandboxing?', content: 'Sandboxing runs...' },
      ]} />
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('Dialog traps focus and has correct ARIA', async () => {
    const { container } = render(
      <ScanDialog isOpen={true} onClose={() => {}} />
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('Form shows errors with role=alert', async () => {
    const { container, getByText } = render(<ScanConfigForm onSubmit={() => {}} />);
    fireEvent.click(getByText('Start Scan'));
    const results = await axe(container);
    expect(results).toHaveNoViolations();
    expect(getByText(/error\(s\) found/)).toBeInTheDocument();
  });
});

Expected output:

PASS  components/accessible-components.test.js
  ✓ Accordion has no violations
  ✓ Dialog traps focus and has correct ARIA
  ✓ Form shows errors with role=alert

Common Mistakes

1. Using div for Everything

React developers often use <div> with onClick instead of <button> or <a>. Always prefer native HTML elements.

2. Not Updating ARIA Attributes in State

Setting aria-expanded once but never updating it when State changes. Always derive ARIA attributes from State.

3. Conditional Rendering Breaking Focus

Conditionally rendering a component with && or ternary operators can cause focus to be lost. Always manage focus with useEffect.

4. Missing Keys in Lists

React keys help the Virtual Dom diff efficiently, but they also help screen readers when content reorders. Always use stable, unique keys.

5. No Role on Custom Interactive Components

A <div> with onClick and tabIndex but no role is announced as an unnamed element. Add appropriate roles.

6. Forgetting aria-live for Dynamic Content

Content that appears after a State change (error messages, success alerts) must have aria-live or role="alert" to be announced.

7. Not Testing with Jest-axe

Manual testing alone misses regressions. Add Jest-axe to your component tests to catch ARIA, contrast, and semantic issues automatically.

Practice Questions

1. Which React hook do you use to focus an element when a component mounts?

useRef to store the element reference, and useEffect with an empty dependency array to call .focus() after mount.

2. How do you trap focus inside a React modal component?

Use useRef to get the modal container, then add a keydown event listener in useEffect that checks for Tab and loops focus between the first and last focusable elements.

3. Why should ARIA attributes be derived from State?

ARIA attributes must reflect the current State of the component. Deriving them from React State ensures they update automatically when State changes.

4. What is the purpose of jest-axe in React Accessibility Testing?

jest-axe runs the axe-core Accessibility engine against rendered React components in unit tests, catching violations early in development.

5. Challenge: Build a React TabPanel component with full keyboard support (arrow keys), aria-selected, aria-controls, role="tablist", and role="tabpanel". Write Jest-axe tests for each State.

Real-World Task

Audit a React component you maintain. Inspect it for: native HTML usage, ARIA attribute correctness (especially aria-expanded, aria-selected), focus management (what happens when it mounts and unmounts), and dynamic content announcements. Fix any issues found.

FAQ

Should I use `aria-label` or `aria-labelledby` in React? Prefer `aria-labelledby` when the label is visible text. Use `aria-label` for icon-only buttons and elements without visible labels.

How do I handle aria-hidden with conditional rendering? Add aria-hidden directly in JSX based on State: <div aria-hidden={!isOpen}>. Do not dynamically add or remove the attribute entirely.

Can React Server Components be accessible? Yes, RSC focuses on static content. Interactive patterns remain in client components. Use semantic HTML in RSC for heading hierarchy and landmarks.

Do React portals affect Accessibility? Portals move Dom nodes but do not affect the Accessibility tree. Focus management and ARIA relationships still work. Ensure the portal container is not hidden.

How do I test keyboard interactions in React? Use fireEvent.keyDown from @testing-library/React. Simulate Tab, Enter, Escape, and arrow key presses. Assert focus moves to the expected element.

Try It Yourself

Build a React component for DodaZIP's file list with keyboard reordering:

function ReorderableFileList({ files, onReorder }) {
  const [items, setItems] = useState(files);

  function handleKeyDown(e, index) {
    if (e.key === 'ArrowUp' && index > 0) {
      e.preventDefault();
      const newItems = [...items];
      [newItems[index - 1], newItems[index]] = [newItems[index], newItems[index - 1]];
      setItems(newItems);
    } else if (e.key === 'ArrowDown' && index < items.length - 1) {
      e.preventDefault();
      const newItems = [...items];
      [newItems[index], newItems[index + 1]] = [newItems[index + 1], newItems[index]];
      setItems(newItems);
    }
  }

  return (
    <ul role="list" aria-label="File order in archive">
      {items.map((file, index) => (
        <li key={file.id} role="listitem"
            tabIndex={0}
            aria-label={`${file.name}, position ${index + 1}`}
            onKeyDown={(e) => handleKeyDown(e, index)}>
          {file.name}
        </li>
      ))}
    </ul>
  );
}

What's Next

Focus Management Deep Dive
ARIA — Complete Guide
Accessible Modals & Dialogs

Congratulations on completing this React Accessibility tutorial! Here is where to Go from here:

  • Practice daily — Add Jest-axe tests to one React component per day
  • Build a project — Create an accessible React component library
  • Explore related topics — Dive deeper into focus management next
  • Join the community — Discuss with other learners and share your progress

Remember: every expert was once a beginner. Keep coding!

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro