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 tabsrole="tab"- Individual tab buttonrole="tabpanel"- Content panelaria-selected- Indicates active tabaria-controls- Links tab to its panelaria-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 openaria-controls- Links button to menurole="menu"- Identifies the dropdown listrole="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-labelfor icon-only buttons -
✅ Update
aria-expanded,aria-selecteddynamically -
✅ Provide
aria-liveregions for dynamic content -
✅ Hide decorative elements with
aria-hidden="true" -
✅ Link labels to inputs with
aria-labelledbyoraria-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!