React Accessibility: ARIA Attributes and Keyboard Navigation

Web accessibility isn't optional anymore. With lawsuits increasing and WCAG 2.1 becoming a legal requirement in many jurisdictions, building accessible React applications is both a legal necessity...

Key Insights

  • Semantic HTML should always be your first choice—ARIA attributes are meant to fill gaps where native elements fall short, not replace proper markup
  • Keyboard navigation requires explicit focus management in React, especially for modals, dropdowns, and dynamic content where the default tab order breaks down
  • Accessibility testing must go beyond automated tools; combine axe-core with manual keyboard testing and actual screen reader validation to catch real-world issues

Introduction to Web Accessibility in React

Web accessibility isn’t optional anymore. With lawsuits increasing and WCAG 2.1 becoming a legal requirement in many jurisdictions, building accessible React applications is both a legal necessity and an ethical imperative. Beyond compliance, accessible applications simply work better for everyone—keyboard shortcuts benefit power users, proper semantic structure improves SEO, and clear focus indicators help all users understand interface state.

Modern React applications introduce unique accessibility challenges. Single-page applications break traditional browser navigation patterns, client-side routing disrupts screen reader announcements, and custom components often reinvent native browser functionality poorly. The framework’s component-based architecture can help or hurt accessibility depending on how you use it.

This article covers practical patterns for implementing ARIA attributes and keyboard navigation in React. You’ll learn when to use ARIA, how to manage focus programmatically, and how to test your components for real accessibility.

Understanding ARIA Attributes

ARIA (Accessible Rich Internet Applications) provides attributes that communicate component roles, states, and properties to assistive technologies. However, ARIA comes with a critical rule: no ARIA is better than bad ARIA.

ARIA has three main categories:

  • Roles define what an element is (role="dialog", role="tabpanel")
  • Properties describe characteristics (aria-label, aria-labelledby)
  • States communicate dynamic values (aria-expanded, aria-disabled)

Use semantic HTML first. A <button> element automatically communicates its role, accepts keyboard focus, and responds to Enter and Space keys. Adding role="button" to a <div> gives you the role but none of the behavior—you’ll need to implement everything manually.

Here’s a properly structured modal dialog with ARIA attributes:

function Modal({ isOpen, onClose, title, children }) {
  const modalRef = useRef(null);

  useEffect(() => {
    if (isOpen) {
      modalRef.current?.focus();
    }
  }, [isOpen]);

  if (!isOpen) return null;

  return (
    <div
      className="modal-overlay"
      onClick={onClose}
      role="presentation"
    >
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby="modal-description"
        tabIndex={-1}
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        <div id="modal-description">
          {children}
        </div>
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

The aria-labelledby and aria-describedby attributes reference element IDs rather than duplicating text. This creates proper semantic relationships that screen readers announce when the dialog opens. The aria-modal="true" attribute tells assistive technologies that content outside the modal should be hidden.

Implementing Keyboard Navigation

Keyboard navigation goes beyond making elements focusable. You need to handle specific keys appropriately and manage focus order logically. Standard keyboard patterns include:

  • Tab/Shift+Tab: Move between focusable elements
  • Enter/Space: Activate buttons and controls
  • Escape: Close dialogs and dismiss menus
  • Arrow keys: Navigate within composite widgets (menus, tabs, lists)

Here’s a custom dropdown menu with complete keyboard support:

function Dropdown({ label, options, onChange }) {
  const [isOpen, setIsOpen] = useState(false);
  const [selectedIndex, setSelectedIndex] = useState(-1);
  const buttonRef = useRef(null);
  const menuRef = useRef(null);

  const handleKeyDown = (e) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        if (!isOpen) {
          setIsOpen(true);
          setSelectedIndex(0);
        } else {
          setSelectedIndex((prev) => 
            prev < options.length - 1 ? prev + 1 : prev
          );
        }
        break;

      case 'ArrowUp':
        e.preventDefault();
        if (isOpen) {
          setSelectedIndex((prev) => prev > 0 ? prev - 1 : prev);
        }
        break;

      case 'Enter':
      case ' ':
        e.preventDefault();
        if (isOpen && selectedIndex >= 0) {
          onChange(options[selectedIndex]);
          setIsOpen(false);
          buttonRef.current?.focus();
        } else {
          setIsOpen(!isOpen);
        }
        break;

      case 'Escape':
        e.preventDefault();
        setIsOpen(false);
        buttonRef.current?.focus();
        break;

      case 'Home':
        e.preventDefault();
        setSelectedIndex(0);
        break;

      case 'End':
        e.preventDefault();
        setSelectedIndex(options.length - 1);
        break;
    }
  };

  useEffect(() => {
    if (isOpen && selectedIndex >= 0) {
      const selectedOption = menuRef.current?.children[selectedIndex];
      selectedOption?.scrollIntoView({ block: 'nearest' });
    }
  }, [selectedIndex, isOpen]);

  return (
    <div className="dropdown">
      <button
        ref={buttonRef}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
      >
        {label}
      </button>
      {isOpen && (
        <ul
          ref={menuRef}
          role="listbox"
          onKeyDown={handleKeyDown}
          tabIndex={-1}
        >
          {options.map((option, index) => (
            <li
              key={option.value}
              role="option"
              aria-selected={index === selectedIndex}
              onClick={() => {
                onChange(option);
                setIsOpen(false);
              }}
            >
              {option.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

This implementation prevents default behavior for arrow keys (which would scroll the page), handles Home/End for quick navigation, and maintains proper focus when closing the menu.

Building Accessible Custom Components

Accordions are common UI patterns that require careful ARIA implementation. Each accordion button must communicate whether its panel is expanded or collapsed:

function Accordion({ items }) {
  const [expandedIndex, setExpandedIndex] = useState(null);

  const handleKeyDown = (e, index) => {
    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        const nextIndex = index < items.length - 1 ? index + 1 : 0;
        document.getElementById(`accordion-button-${nextIndex}`)?.focus();
        break;

      case 'ArrowUp':
        e.preventDefault();
        const prevIndex = index > 0 ? index - 1 : items.length - 1;
        document.getElementById(`accordion-button-${prevIndex}`)?.focus();
        break;

      case 'Home':
        e.preventDefault();
        document.getElementById('accordion-button-0')?.focus();
        break;

      case 'End':
        e.preventDefault();
        document.getElementById(`accordion-button-${items.length - 1}`)?.focus();
        break;
    }
  };

  return (
    <div className="accordion">
      {items.map((item, index) => {
        const isExpanded = expandedIndex === index;
        const buttonId = `accordion-button-${index}`;
        const panelId = `accordion-panel-${index}`;

        return (
          <div key={index} className="accordion-item">
            <h3>
              <button
                id={buttonId}
                aria-expanded={isExpanded}
                aria-controls={panelId}
                onClick={() => setExpandedIndex(isExpanded ? null : index)}
                onKeyDown={(e) => handleKeyDown(e, index)}
              >
                {item.title}
              </button>
            </h3>
            <div
              id={panelId}
              role="region"
              aria-labelledby={buttonId}
              hidden={!isExpanded}
            >
              {item.content}
            </div>
          </div>
        );
      })}
    </div>
  );
}

The aria-controls attribute creates a programmatic relationship between button and panel. The hidden attribute removes collapsed panels from the accessibility tree entirely, preventing screen readers from navigating to hidden content.

Focus Management Patterns

Managing focus programmatically is crucial for accessible React applications. When a modal closes, focus should return to the element that triggered it. When content loads dynamically, focus should move to that content or an appropriate heading.

Here’s a reusable focus trap hook for modals:

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

  useEffect(() => {
    if (!isActive) return;

    // Store the currently focused element
    previousFocusRef.current = document.activeElement;

    const container = containerRef.current;
    if (!container) return;

    // Get all focusable elements
    const focusableElements = container.querySelectorAll(
      'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );

    const firstElement = focusableElements[0];
    const lastElement = focusableElements[focusableElements.length - 1];

    // Focus the first element
    firstElement?.focus();

    const handleTabKey = (e) => {
      if (e.key !== 'Tab') return;

      if (e.shiftKey) {
        if (document.activeElement === firstElement) {
          e.preventDefault();
          lastElement?.focus();
        }
      } else {
        if (document.activeElement === lastElement) {
          e.preventDefault();
          firstElement?.focus();
        }
      }
    };

    container.addEventListener('keydown', handleTabKey);

    return () => {
      container.removeEventListener('keydown', handleTabKey);
      // Restore focus when component unmounts
      previousFocusRef.current?.focus();
    };
  }, [isActive]);

  return containerRef;
}

// Usage
function Modal({ isOpen, onClose, children }) {
  const modalRef = useFocusTrap(isOpen);

  if (!isOpen) return null;

  return (
    <div ref={modalRef} role="dialog" aria-modal="true">
      {children}
    </div>
  );
}

This hook captures focus when activated, traps Tab navigation within the container, and restores focus to the previously focused element when deactivated.

Testing and Validation

Automated testing catches many accessibility issues but can’t replace manual testing. Use a combination of tools:

import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

describe('Dropdown accessibility', () => {
  it('should have no accessibility violations', async () => {
    const { container } = render(
      <Dropdown label="Select option" options={options} onChange={jest.fn()} />
    );
    const results = await axe(container);
    expect(results).toHaveNoViolations();
  });

  it('should open on Enter key', async () => {
    const user = userEvent.setup();
    render(<Dropdown label="Select option" options={options} onChange={jest.fn()} />);
    
    const button = screen.getByRole('button', { name: /select option/i });
    await user.tab(); // Focus the button
    await user.keyboard('{Enter}');
    
    expect(screen.getByRole('listbox')).toBeInTheDocument();
  });

  it('should navigate options with arrow keys', async () => {
    const user = userEvent.setup();
    const onChange = jest.fn();
    render(<Dropdown label="Select" options={options} onChange={onChange} />);
    
    await user.tab();
    await user.keyboard('{Enter}'); // Open menu
    await user.keyboard('{ArrowDown}'); // Navigate to first option
    await user.keyboard('{Enter}'); // Select option
    
    expect(onChange).toHaveBeenCalledWith(options[0]);
  });
});

Testing Library’s accessibility queries (getByRole, getByLabelText) encourage accessible patterns by making it difficult to select elements that lack proper ARIA attributes or semantic markup.

Best Practices and Common Patterns

Follow these guidelines for accessible React components:

Always prefer semantic HTML. Use <button> instead of <div onClick>, <nav> for navigation, and <main> for primary content.

Manage focus explicitly. Don’t rely on default browser behavior for modals, route changes, or dynamic content updates.

Test with actual keyboards and screen readers. NVDA (Windows) and VoiceOver (Mac) are free and reveal issues automated tools miss.

Provide visible focus indicators. Never set outline: none without providing an alternative focus style.

Announce dynamic changes. Use live regions (aria-live) for status messages and loading states that appear without user interaction.

Keep ARIA attributes synchronized. If you toggle aria-expanded, ensure the actual expanded state matches. Mismatches confuse assistive technology users.

For ongoing learning, consult the WAI-ARIA Authoring Practices Guide for official patterns and the A11y Project for practical checklists.

Accessibility isn’t a feature you add at the end—it’s a fundamental aspect of component design that becomes easier when baked in from the start. Build it into your component library, add it to your code review checklist, and make it part of your definition of done.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.