ARIA Attributes Cheat Sheet for React Developers
What Is ARIA?
ARIA (Accessible Rich Internet Applications) is a set of HTML attributes that tell assistive technologies like screen readers what a UI element is, what state it's in, and how it relates to other elements. When native HTML elements don't communicate enough information on their own, ARIA fills the gap.
The first rule of ARIA is: don't use ARIA if native HTML does the job. A <button> is better than <div role="button">. A <nav> is better than <div role="navigation">. Native elements come with built-in keyboard behavior, focus management, and screen reader announcements. ARIA adds none of these. It only changes what assistive technology announces.
That said, modern web apps are full of patterns that native HTML doesn't cover: tabs, accordions, dialogs, comboboxes, tree views, live notifications, and custom widgets. For these, ARIA is essential.
This cheat sheet covers the ARIA attributes you'll actually use in React and Next.js applications, organized by the pattern they solve.
ARIA Roles
Roles tell assistive technology what an element is. They override the element's native role.
Landmark Roles
Landmarks let screen reader users jump between major page sections. Use native HTML elements when possible (they have implicit roles), and add ARIA roles when you can't.
| HTML Element | Implicit Role | When to Use ARIA Instead |
|---|---|---|
<header> |
banner |
Only if not using semantic HTML |
<nav> |
navigation |
Multiple navs need aria-label to distinguish |
<main> |
main |
Only if not using semantic HTML |
<aside> |
complementary |
Only if not using semantic HTML |
<footer> |
contentinfo |
Only if not using semantic HTML |
<section> |
region (if labeled) |
Must have aria-label or aria-labelledby |
<form> |
form (if labeled) |
Must have aria-label or aria-labelledby |
// Good: Native elements with labels for multiple navs
<nav aria-label="Main navigation">
{/* Primary nav items */}
</nav>
<nav aria-label="Footer navigation">
{/* Footer links */}
</nav>
// Good: Named section
<section aria-labelledby="settings-heading">
<h2 id="settings-heading">Account Settings</h2>
{/* Settings content */}
</section>
Widget Roles
For custom interactive components that don't have native HTML equivalents.
| Role | Purpose | Common Usage |
|---|---|---|
dialog |
Modal or non-modal dialog | Modals, confirmation prompts, lightboxes |
alertdialog |
Dialog that requires immediate attention | Delete confirmation, unsaved changes warning |
tablist / tab / tabpanel |
Tabbed interface | Settings tabs, dashboard views |
menu / menuitem |
Action menu | Context menus, dropdown action menus |
menubar / menuitem |
Horizontal menu bar | Application menu bars |
listbox / option |
Selectable list | Custom select dropdowns, command palettes |
combobox |
Text input with popup list | Autocomplete, search suggestions |
tree / treeitem |
Hierarchical list | File browsers, nested navigation |
grid / row / gridcell |
Interactive data grid | Spreadsheet-like interfaces |
toolbar |
Group of controls | Text editor toolbars, formatting bars |
tooltip |
Descriptive popup | Hover/focus info popups |
switch |
Binary toggle | On/off toggles (distinct from checkbox) |
status |
Live region for status updates | Save confirmations, sync indicators |
alert |
Urgent live region | Error messages, warnings |
progressbar |
Progress indicator | File uploads, loading states |
ARIA States and Properties
States describe the current condition of an element. Properties describe relationships between elements.
Toggle and Selection States
// Accordion: expanded/collapsed state
<button aria-expanded={isOpen} aria-controls="panel-1">
Account Settings
</button>
<div id="panel-1" role="region" hidden={!isOpen}>
{/* Panel content */}
</div>
// Checkbox: checked state
<div
role="checkbox"
aria-checked={isChecked}
tabIndex={0}
onClick={toggle}
onKeyDown={(e) => e.key === ' ' && toggle()}
>
{label}
</div>
// Switch/toggle: on/off
<button role="switch" aria-checked={isEnabled} onClick={toggle}>
Dark mode: {isEnabled ? 'On' : 'Off'}
</button>
// Tab: selected state
<button role="tab" aria-selected={isActive} tabIndex={isActive ? 0 : -1}>
General
</button>
// Menu item: checked (for toggle menu items)
<div role="menuitemcheckbox" aria-checked={isChecked}>
Show notifications
</div>
// Radio-style selection
<div role="menuitemradio" aria-checked={isSelected}>
Sort by name
</div>
// Tree item: expanded
<div role="treeitem" aria-expanded={isOpen} aria-level={2}>
Components
</div>
Disabled and Busy States
// Disabled: element exists but is not interactive
<button aria-disabled={!canSubmit} onClick={canSubmit ? handleSubmit : undefined}>
Submit
</button>
// Note: aria-disabled keeps the element in tab order (unlike HTML disabled)
// Use HTML disabled when you want to remove from tab order entirely:
<button disabled={!canSubmit}>Submit</button>
// Busy: element is loading or updating
<div aria-busy={isLoading} aria-live="polite">
{isLoading ? <Spinner /> : content}
</div>
// Current: indicates current item in navigation
<a href="/dashboard" aria-current="page">Dashboard</a>
<a href="/settings">Settings</a>
// Invalid: form field has error
<input
aria-invalid={hasError}
aria-describedby={hasError ? 'email-error' : undefined}
/>
{hasError && <span id="email-error" role="alert">Enter a valid email address</span>}
Progress and Value States
// Progress bar
<div
role="progressbar"
aria-valuenow={65}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Upload progress"
>
65%
</div>
// Indeterminate progress (no known completion)
<div role="progressbar" aria-label="Loading data">
<Spinner />
</div>
// Slider
<input
type="range"
role="slider"
aria-valuenow={volume}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Volume"
/>
Labeling and Describing
These are the attributes you'll use most often. They connect elements to their labels and descriptions.
aria-label
Provides a text label when there's no visible label. Use for icon buttons, landmark sections, and elements where the visible text doesn't fully describe the purpose.
// Icon button with no visible text
<button aria-label="Close dialog" onClick={onClose}>
<XIcon aria-hidden="true" />
</button>
// Search input without visible label
<input type="search" aria-label="Search templates" placeholder="Search..." />
// Multiple navigation landmarks
<nav aria-label="Main">...</nav>
<nav aria-label="Breadcrumb">...</nav>
aria-labelledby
Points to another element whose text content serves as the label. Preferred over aria-label when a visible label exists because it uses the visible text, keeping the label consistent.
// Dialog labeled by its heading
<div role="dialog" aria-labelledby="dialog-title" aria-modal="true">
<h2 id="dialog-title">Delete Account</h2>
<p>This action cannot be undone.</p>
</div>
// Form section labeled by a heading
<section aria-labelledby="billing-heading">
<h3 id="billing-heading">Billing Information</h3>
<input type="text" aria-label="Card number" />
</section>
// Can reference multiple elements
<button aria-labelledby="action-label item-name">
<span id="action-label">Delete</span>
</button>
{/* Where item-name is defined elsewhere: */}
<span id="item-name">Project Alpha</span>
{/* Screen reader announces: "Delete Project Alpha" */}
aria-describedby
Points to an element that provides additional description. Unlike aria-labelledby (which is the name), aria-describedby gives supplementary info read after the name.
// Input with help text
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
aria-describedby="password-help password-error"
/>
<span id="password-help">Must be at least 8 characters</span>
{error && <span id="password-error" role="alert">{error}</span>}
// Button with additional context
<button aria-describedby="delete-warning">Delete account</button>
<p id="delete-warning">This will permanently remove all your data.</p>
aria-hidden
Removes an element from the accessibility tree. The element is still visible but invisible to screen readers. Use for decorative elements, icons that have text labels, and duplicate content.
// Decorative icon next to text
<button>
<TrashIcon aria-hidden="true" />
Delete
</button>
// Decorative background image
<div aria-hidden="true" className="background-pattern" />
// NEVER hide interactive elements
// Bad: <button aria-hidden="true">Submit</button>
Relationship Attributes
These connect elements that are logically related but not parent-child in the DOM.
aria-controls
Identifies the element that this element controls. Used with buttons that show/hide content.
<button aria-controls="settings-panel" aria-expanded={isOpen}>
Settings
</button>
<div id="settings-panel" hidden={!isOpen}>
{/* Panel content */}
</div>
aria-owns
Indicates that an element logically owns elements that are not its DOM children. Use when DOM structure doesn't match logical structure (rare but necessary for portaled content like dropdowns rendered in a body-level container).
// Combobox with portaled listbox
<div role="combobox" aria-owns="suggestion-list" aria-expanded={isOpen}>
<input aria-autocomplete="list" aria-activedescendant={activeId} />
</div>
{/* Rendered in a portal */}
<ul id="suggestion-list" role="listbox">
<li role="option" id="option-1">React</li>
<li role="option" id="option-2">Next.js</li>
</ul>
aria-activedescendant
Identifies the currently active child in a composite widget. Used in listboxes, menus, and trees where focus stays on the container and arrow keys move the active descendant.
<div
role="listbox"
tabIndex={0}
aria-activedescendant={`option-${activeIndex}`}
onKeyDown={handleArrowKeys}
>
{options.map((option, i) => (
<div
key={option.id}
id={`option-${i}`}
role="option"
aria-selected={i === activeIndex}
>
{option.label}
</div>
))}
</div>
Live Regions
Live regions announce dynamic content changes to screen readers without moving focus. This is critical for notifications, loading states, error messages, and real-time updates.
aria-live
// Polite: Announced after the user finishes current task
<div aria-live="polite">
{saveStatus === 'saved' && 'All changes saved'}
</div>
// Assertive: Announced immediately, interrupting current speech
<div aria-live="assertive">
{connectionLost && 'Connection lost. Reconnecting...'}
</div>
role="status" (Polite Live Region)
Shorthand for aria-live="polite". Use for non-urgent status updates.
// Search results count
<div role="status">
{results.length} results found
</div>
// Save indicator
<div role="status">
{isSaving ? 'Saving...' : 'Saved'}
</div>
// Toast notification
<div role="status" className="toast">
Settings updated successfully
</div>
role="alert" (Assertive Live Region)
Shorthand for aria-live="assertive". Use for urgent messages that require immediate attention.
// Form error
<div role="alert">
{formError && `Error: ${formError}`}
</div>
// Session expiry warning
<div role="alert">
Your session will expire in 2 minutes. Save your work.
</div>
Live Regions for AI Streaming
AI chat interfaces need live regions for streaming responses. The AI UX Kit handles this automatically, but here's the pattern:
// Streaming AI response
<div
aria-live="polite"
aria-atomic="false"
aria-busy={isStreaming}
>
{streamedContent}
</div>
// aria-atomic="false": Only announce new content, not the entire region
// aria-busy="true" during streaming: Tells screen readers to wait before announcing
For more on AI chat accessibility, see AI Chat UI Best Practices and Keyboard Navigation Patterns for Web Apps.
Complete Patterns: Copy-Paste JSX
Modal Dialog
<div
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
aria-describedby="modal-description"
>
<h2 id="modal-title">Confirm Deletion</h2>
<p id="modal-description">
This will permanently delete your account and all data.
</p>
<button onClick={onCancel}>Cancel</button>
<button onClick={onConfirm}>Delete Account</button>
</div>
Tab Interface
<div role="tablist" aria-label="Settings sections">
<button role="tab" aria-selected={true} aria-controls="panel-general" id="tab-general" tabIndex={0}>
General
</button>
<button role="tab" aria-selected={false} aria-controls="panel-billing" id="tab-billing" tabIndex={-1}>
Billing
</button>
</div>
<div role="tabpanel" id="panel-general" aria-labelledby="tab-general" tabIndex={0}>
{/* General settings content */}
</div>
<div role="tabpanel" id="panel-billing" aria-labelledby="tab-billing" tabIndex={0} hidden>
{/* Billing settings content */}
</div>
Form with Errors
<form aria-label="Account settings" noValidate onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email address</label>
<input
id="email"
type="email"
aria-required="true"
aria-invalid={!!errors.email}
aria-describedby={errors.email ? 'email-error' : 'email-hint'}
/>
<span id="email-hint">We'll send a confirmation to this address</span>
{errors.email && (
<span id="email-error" role="alert">{errors.email}</span>
)}
</div>
<button type="submit" aria-disabled={isSubmitting}>
{isSubmitting ? 'Saving...' : 'Save Changes'}
</button>
<div role="status" aria-live="polite">
{saved && 'Changes saved successfully'}
</div>
</form>
Data Table with Sorting
<table aria-label="Team members">
<thead>
<tr>
<th scope="col" aria-sort={sortColumn === 'name' ? sortDirection : 'none'}>
<button onClick={() => sort('name')}>
Name
{sortColumn === 'name' && (
<span aria-hidden="true">{sortDirection === 'ascending' ? ' ▲' : ' ▼'}</span>
)}
</button>
</th>
<th scope="col" aria-sort={sortColumn === 'role' ? sortDirection : 'none'}>
<button onClick={() => sort('role')}>Role</button>
</th>
</tr>
</thead>
<tbody>
{rows.map(row => (
<tr key={row.id}>
<td>{row.name}</td>
<td>{row.role}</td>
</tr>
))}
</tbody>
</table>
<div role="status" aria-live="polite">
Sorted by {sortColumn}, {sortDirection}
</div>
Navigation with Current Page
<nav aria-label="Main navigation">
<ul>
<li><a href="/dashboard" aria-current={pathname === '/dashboard' ? 'page' : undefined}>Dashboard</a></li>
<li><a href="/projects" aria-current={pathname === '/projects' ? 'page' : undefined}>Projects</a></li>
<li><a href="/settings" aria-current={pathname === '/settings' ? 'page' : undefined}>Settings</a></li>
</ul>
</nav>
Quick Reference Table
| What You Need | ARIA Attribute | Example Value |
|---|---|---|
| Label an icon button | aria-label |
"Close" |
| Point to a visible label | aria-labelledby |
"heading-id" |
| Add help text to an input | aria-describedby |
"hint-id" |
| Hide decorative element | aria-hidden |
"true" |
| Show expanded/collapsed state | aria-expanded |
true / false |
| Mark selected tab or option | aria-selected |
true / false |
| Mark checked checkbox/switch | aria-checked |
true / false / "mixed" |
| Show invalid form field | aria-invalid |
true / false |
| Mark required field | aria-required |
"true" |
| Disable without removing from tab order | aria-disabled |
"true" |
| Connect trigger to content | aria-controls |
"panel-id" |
| Announce status updates | role="status" |
(on the container) |
| Announce urgent errors | role="alert" |
(on the container) |
| Mark current page in nav | aria-current |
"page" |
| Indicate sort direction | aria-sort |
"ascending" / "descending" / "none" |
| Show loading state | aria-busy |
true / false |
Don't Reinvent It
Every pattern in this cheat sheet is already implemented in component libraries that handle ARIA correctly:
- shadcn/ui + Radix UI: Used by the thefrontkit SaaS Starter Kit. All dialogs, menus, tabs, accordions, tooltips, and form components include correct ARIA attributes.
- Headless UI: Accessible components from the Tailwind team.
- React Aria (Adobe): Hooks for building custom accessible components.
The thefrontkit SaaS Starter Kit and AI UX Kit ship with every ARIA pattern in this guide already implemented and tested. Modals, dropdowns, tabs, data tables, forms, navigation, toast notifications, and AI streaming interfaces all include correct roles, states, properties, and live regions.
Related reading:
- WCAG AA Checklist for Web Apps
- What Is WCAG 2.1 AA?
- Keyboard Navigation Patterns for Web Apps
- Building an Accessible React Dashboard
- Form Validation That Doesn't Frustrate Users
Start with accessible components: View SaaS Starter Kit | View AI UX Kit | Browse all templates