Back to Blog

ARIA Patterns in React: Complete Beginner's Guide

ARIA (Accessible Rich Internet Applications) makes your React apps work for everyone, including users with disabilities. In this guide, you'll learn the most common ARIA patterns with real React examples—from tabs and accordions to dropdowns and modals. Let's make your apps truly accessible!

What is ARIA and Why Does It Matter?

ARIA is a set of attributes that define ways to make web content more accessible. Think of ARIA as a translator between your complex React components and assistive technologies like screen readers.

  • Roles - Define what an element is (button, tab, dialog)
  • States - Describe the current condition (checked, expanded, selected)
  • Properties - Provide additional information (label, description, controls)
First Rule of ARIA: Don't use ARIA if you can use semantic HTML instead. Use <button> before <div role="button">.

Pattern 1: Accessible Tabs

Tabs are one of the most common UI patterns. Here's how to make them accessible:

import React, { useState, useRef, useEffect } from 'react';

function AccessibleTabs({ tabs }) {
  const [activeTab, setActiveTab] = useState(0);
  const tabRefs = useRef([]);

  // Keyboard navigation
  const handleKeyDown = (event, index) => {
    const { key } = event;
    let newIndex = index;

    if (key === 'ArrowRight') {
      newIndex = (index + 1) % tabs.length;
    } else if (key === 'ArrowLeft') {
      newIndex = (index - 1 + tabs.length) % tabs.length;
    } else if (key === 'Home') {
      newIndex = 0;
    } else if (key === 'End') {
      newIndex = tabs.length - 1;
    } else {
      return; // Don't prevent default for other keys
    }

    event.preventDefault();
    setActiveTab(newIndex);
    tabRefs.current[newIndex]?.focus();
  };

  return (
    <div className="tabs">
      {/* Tab list with proper ARIA attributes */}
      <div role="tablist" aria-label="Content sections">
        {tabs.map((tab, index) => (
          <button
            key={tab.id}
            ref={(el) => (tabRefs.current[index] = el)}
            role="tab"
            aria-selected={activeTab === index}
            aria-controls={`panel-${tab.id}`}
            id={`tab-${tab.id}`}
            tabIndex={activeTab === index ? 0 : -1}
            onClick={() => setActiveTab(index)}
            onKeyDown={(e) => handleKeyDown(e, index)}
            className={activeTab === index ? 'active' : ''}
          >
            {tab.label}
          </button>
        ))}
      </div>

      {/* Tab panels */}
      {tabs.map((tab, index) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={activeTab !== index}
          tabIndex={0}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

// Usage
function App() {
  const tabs = [
    { id: 'about', label: 'About', content: <p>About content</p> },
    { id: 'services', label: 'Services', content: <p>Services content</p> },
    { id: 'contact', label: 'Contact', content: <p>Contact content</p> },
  ];

  return <AccessibleTabs tabs={tabs} />;
}

Key ARIA Attributes for Tabs:

  • role="tablist" - Container for tabs
  • role="tab" - Individual tab button
  • role="tabpanel" - Content panel
  • aria-selected - Indicates active tab
  • aria-controls - Links tab to its panel
  • aria-labelledby - Links panel to its tab
  • tabIndex={-1} - Removes inactive tabs from tab order

Pattern 2: Accordion Component

Accordions hide and show content. Here's the accessible way to build them:

import React, { useState } from 'react';

function AccordionItem({ title, content, isOpen, onToggle, id }) {
  const headerId = `accordion-header-${id}`;
  const panelId = `accordion-panel-${id}`;

  return (
    <div className="accordion-item">
      <h3>
        <button
          id={headerId}
          aria-expanded={isOpen}
          aria-controls={panelId}
          onClick={onToggle}
          className="accordion-button"
        >
          <span>{title}</span>
          <svg
            aria-hidden="true"
            className={`icon ${isOpen ? 'rotate' : ''}`}
            viewBox="0 0 24 24"
          >
            <path d="M19 9l-7 7-7-7" />
          </svg>
        </button>
      </h3>
      
      <div
        id={panelId}
        role="region"
        aria-labelledby={headerId}
        hidden={!isOpen}
        className="accordion-panel"
      >
        {content}
      </div>
    </div>
  );
}

function Accordion({ items, allowMultiple = false }) {
  const [openItems, setOpenItems] = useState(new Set([0]));

  const toggleItem = (index) => {
    setOpenItems((prev) => {
      const newSet = allowMultiple ? new Set(prev) : new Set();
      if (prev.has(index)) {
        newSet.delete(index);
      } else {
        newSet.add(index);
      }
      return newSet;
    });
  };

  return (
    <div className="accordion">
      {items.map((item, index) => (
        <AccordionItem
          key={index}
          id={index}
          title={item.title}
          content={item.content}
          isOpen={openItems.has(index)}
          onToggle={() => toggleItem(index)}
        />
      ))}
    </div>
  );
}

// Usage
const faqItems = [
  { title: 'What is React?', content: 'React is a JavaScript library...' },
  { title: 'What is ARIA?', content: 'ARIA stands for...' },
  { title: 'Why accessibility?', content: 'Accessibility ensures...' },
];

<Accordion items={faqItems} allowMultiple={true} />

Key ARIA Attributes for Accordions:

  • aria-expanded - Shows if panel is open or closed
  • aria-controls - Links button to panel it controls
  • role="region" - Identifies the expandable content
  • aria-labelledby - Connects panel to its header
  • hidden - Hides panel from screen readers when closed

Pattern 3: Dropdown Menu

Dropdowns require careful ARIA implementation for proper screen reader support:

import React, { useState, useRef, useEffect } from 'react';

function Dropdown({ label, items }) {
  const [isOpen, setIsOpen] = useState(false);
  const [focusedIndex, setFocusedIndex] = useState(-1);
  const buttonRef = useRef(null);
  const menuRef = useRef(null);
  const itemRefs = useRef([]);

  // Close on outside click
  useEffect(() => {
    const handleClickOutside = (event) => {
      if (
        menuRef.current &&
        !menuRef.current.contains(event.target) &&
        !buttonRef.current.contains(event.target)
      ) {
        setIsOpen(false);
      }
    };

    if (isOpen) {
      document.addEventListener('mousedown', handleClickOutside);
    }

    return () => {
      document.removeEventListener('mousedown', handleClickOutside);
    };
  }, [isOpen]);

  const handleKeyDown = (event) => {
    const { key } = event;

    if (key === 'Escape') {
      setIsOpen(false);
      buttonRef.current?.focus();
      return;
    }

    if (!isOpen) {
      if (key === 'ArrowDown' || key === 'Enter' || key === ' ') {
        event.preventDefault();
        setIsOpen(true);
        setFocusedIndex(0);
      }
      return;
    }

    // Menu is open
    let newIndex = focusedIndex;

    if (key === 'ArrowDown') {
      event.preventDefault();
      newIndex = (focusedIndex + 1) % items.length;
    } else if (key === 'ArrowUp') {
      event.preventDefault();
      newIndex = (focusedIndex - 1 + items.length) % items.length;
    } else if (key === 'Home') {
      event.preventDefault();
      newIndex = 0;
    } else if (key === 'End') {
      event.preventDefault();
      newIndex = items.length - 1;
    } else if (key === 'Enter' || key === ' ') {
      event.preventDefault();
      if (focusedIndex >= 0) {
        handleItemClick(items[focusedIndex]);
      }
      return;
    }

    setFocusedIndex(newIndex);
  };

  useEffect(() => {
    if (isOpen && focusedIndex >= 0) {
      itemRefs.current[focusedIndex]?.focus();
    }
  }, [focusedIndex, isOpen]);

  const handleItemClick = (item) => {
    item.onClick?.();
    setIsOpen(false);
    buttonRef.current?.focus();
  };

  return (
    <div className="dropdown">
      <button
        ref={buttonRef}
        aria-haspopup="true"
        aria-expanded={isOpen}
        aria-controls="dropdown-menu"
        onClick={() => setIsOpen(!isOpen)}
        onKeyDown={handleKeyDown}
        className="dropdown-button"
      >
        {label}
        <svg aria-hidden="true" className="icon" viewBox="0 0 24 24">
          <path d="M19 9l-7 7-7-7" />
        </svg>
      </button>

      {isOpen && (
        <ul
          ref={menuRef}
          id="dropdown-menu"
          role="menu"
          aria-labelledby="dropdown-button"
          className="dropdown-menu"
        >
          {items.map((item, index) => (
            <li key={index} role="none">
              <button
                ref={(el) => (itemRefs.current[index] = el)}
                role="menuitem"
                tabIndex={-1}
                onClick={() => handleItemClick(item)}
                onKeyDown={handleKeyDown}
                className="dropdown-item"
              >
                {item.label}
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

// Usage
const menuItems = [
  { label: 'Profile', onClick: () => console.log('Profile') },
  { label: 'Settings', onClick: () => console.log('Settings') },
  { label: 'Logout', onClick: () => console.log('Logout') },
];

<Dropdown label="Account" items={menuItems} />

Key ARIA Attributes for Dropdowns:

  • aria-haspopup="true" - Indicates button opens a menu
  • aria-expanded - Shows if menu is open
  • aria-controls - Links button to menu
  • role="menu" - Identifies the dropdown list
  • role="menuitem" - Identifies menu options
  • role="none" - Removes semantic meaning from <li>

Pattern 4: Alert and Live Regions

Alerts announce important information to screen reader users:

import React, { useState, useEffect } from 'react';

function Alert({ type = 'info', message, onClose, autoClose = 5000 }) {
  useEffect(() => {
    if (autoClose && onClose) {
      const timer = setTimeout(onClose, autoClose);
      return () => clearTimeout(timer);
    }
  }, [autoClose, onClose]);

  // Map type to ARIA role
  const roleMap = {
    error: 'alert', // Immediate announcement
    warning: 'alert',
    success: 'status', // Polite announcement
    info: 'status',
  };

  return (
    <div
      role={roleMap[type]}
      aria-live={type === 'error' || type === 'warning' ? 'assertive' : 'polite'}
      aria-atomic="true"
      className={`alert alert-${type}`}
    >
      <div className="alert-content">
        <span>{message}</span>
        {onClose && (
          <button
            onClick={onClose}
            aria-label="Close alert"
            className="alert-close"
          >
            ✕
          </button>
        )}
      </div>
    </div>
  );
}

// Toast notification system
function ToastContainer() {
  const [toasts, setToasts] = useState([]);

  const addToast = (message, type = 'info') => {
    const id = Date.now();
    setToasts((prev) => [...prev, { id, message, type }]);
  };

  const removeToast = (id) => {
    setToasts((prev) => prev.filter((toast) => toast.id !== id));
  };

  return (
    <>
      {/* Screen reader announcements area */}
      <div
        aria-live="polite"
        aria-atomic="true"
        className="sr-only"
      >
        {toasts.length > 0 && toasts[toasts.length - 1].message}
      </div>

      {/* Visual toasts */}
      <div className="toast-container" aria-label="Notifications">
        {toasts.map((toast) => (
          <Alert
            key={toast.id}
            type={toast.type}
            message={toast.message}
            onClose={() => removeToast(toast.id)}
          />
        ))}
      </div>
    </>
  );
}

ARIA Live Region Attributes:

Attribute Values Use Case
aria-live off, polite, assertive How urgently to announce changes
aria-atomic true, false Announce entire region or just changes
aria-relevant additions, removals, text What changes to announce
role="alert" - Important, time-sensitive info
role="status" - Advisory information

Pattern 5: Modal Dialog

Modals need special ARIA treatment to trap focus and manage screen reader context:

import React, { useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';

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

  // Save focus and set to modal when opened
  useEffect(() => {
    if (isOpen) {
      previousFocusRef.current = document.activeElement;
      
      // Focus first focusable element or modal itself
      const firstFocusable = modalRef.current?.querySelector(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );
      firstFocusable?.focus() || modalRef.current?.focus();

      // Prevent body scroll
      document.body.style.overflow = 'hidden';

      return () => {
        document.body.style.overflow = '';
        previousFocusRef.current?.focus();
      };
    }
  }, [isOpen]);

  // Trap focus inside modal
  const handleKeyDown = (event) => {
    if (event.key === 'Escape') {
      onClose();
      return;
    }

    if (event.key === 'Tab') {
      const focusableElements = modalRef.current?.querySelectorAll(
        'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
      );

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

      if (event.shiftKey && document.activeElement === firstElement) {
        event.preventDefault();
        lastElement.focus();
      } else if (!event.shiftKey && document.activeElement === lastElement) {
        event.preventDefault();
        firstElement.focus();
      }
    }
  };

  if (!isOpen) return null;

  return createPortal(
    <div
      className="modal-overlay"
      onClick={onClose}
      onKeyDown={handleKeyDown}
    >
      <div
        ref={modalRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        aria-describedby="modal-description"
        className="modal"
        onClick={(e) => e.stopPropagation()}
        tabIndex={-1}
      >
        <div className="modal-header">
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close dialog"
            className="modal-close"
          >
            ✕
          </button>
        </div>
        <div id="modal-description" className="modal-body">
          {children}
        </div>
        <div className="modal-footer">
          <button onClick={onClose}>Cancel</button>
          <button onClick={onClose} className="primary">Confirm</button>
        </div>
      </div>
    </div>,
    document.body
  );
}

Essential ARIA Roles Reference

Role Purpose Example
button Clickable action <div role="button">
dialog Modal window <div role="dialog">
tablist Container for tabs <div role="tablist">
menu List of actions <ul role="menu">
alert Important message <div role="alert">
status Advisory info <div role="status">
navigation Nav links <nav role="navigation">

Common ARIA Mistakes to Avoid

❌ Using ARIA Instead of Semantic HTML

// Bad
<div role="button" onClick={handleClick}>Click me</div>

// Good
<button onClick={handleClick}>Click me</button>

❌ Forgetting aria-label on Icon Buttons

// Bad - Screen readers can't understand
<button><IconX /></button>

// Good
<button aria-label="Close"><IconX aria-hidden="true" /></button>

❌ Not Updating Dynamic States

// Bad - State doesn't change
<button aria-expanded="false" onClick={toggle}>Menu</button>

// Good
<button aria-expanded={isOpen} onClick={toggle}>Menu</button>

Testing ARIA Implementation

// Testing with React Testing Library
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

test('accordion expands on click', async () => {
  render(<Accordion items={items} />);
  
  const button = screen.getByRole('button', { name: /what is react/i });
  expect(button).toHaveAttribute('aria-expanded', 'false');
  
  await userEvent.click(button);
  expect(button).toHaveAttribute('aria-expanded', 'true');
  
  const panel = screen.getByRole('region');
  expect(panel).toBeVisible();
});

test('tab navigation works', async () => {
  render(<Tabs tabs={tabs} />);
  
  const tab1 = screen.getByRole('tab', { name: 'Tab 1' });
  const tab2 = screen.getByRole('tab', { name: 'Tab 2' });
  
  await userEvent.click(tab1);
  expect(tab1).toHaveAttribute('aria-selected', 'true');
  
  await userEvent.keyboard('{ArrowRight}');
  expect(tab2).toHaveAttribute('aria-selected', 'true');
});

Best Practices Checklist

  • ✅ Use semantic HTML before adding ARIA
  • ✅ Test with actual screen readers (NVDA, VoiceOver)
  • ✅ Implement keyboard navigation for all interactive elements
  • ✅ Use aria-label for icon-only buttons
  • ✅ Update aria-expanded, aria-selected dynamically
  • ✅ Provide aria-live regions for dynamic content
  • ✅ Hide decorative elements with aria-hidden="true"
  • ✅ Link labels to inputs with aria-labelledby or aria-describedby
  • ✅ Ensure focus management in modals and dialogs
  • ✅ Write automated tests for ARIA attributes

Resources for Learning More

  • ARIA Authoring Practices Guide (APG) - Official ARIA patterns
  • React Aria - Adobe's accessible component library
  • Radix UI - Unstyled accessible components
  • axe DevTools - Browser extension for testing
  • WebAIM - Accessibility testing resources

Key Takeaways

  • ARIA bridges the gap between complex components and assistive tech
  • Always prefer semantic HTML over ARIA roles
  • Common patterns: tabs, accordions, dropdowns, modals, alerts
  • Keep states (expanded, selected) synchronized with visual state
  • Focus management is crucial for modals and menus
  • Test with screen readers, not just automated tools
  • ARIA improves UX for everyone, not just users with disabilities

ARIA patterns might seem complex at first, but they follow predictable conventions. Start with these common patterns, test them with screen readers, and gradually build your accessibility expertise. Your users will appreciate the effort!

💡 Pro tip: Install NVDA on Windows or enable VoiceOver on Mac and navigate your components with your eyes closed. This hands-on experience will teach you more than any documentation!