You're building a custom tab component or an icon-only button in React and the screen reader announces nothing useful. The fix is usually a handful of ARIA attributes, but you need to know which ones belong where.
Below is a concise reference that shows the exact JSX you can copy-paste into a Next.js or React project. It's organized by the UI pattern you're trying to solve, so you can jump straight to the code you need.
ARIA roles
Roles tell assistive technology what an element is. They override the element's native role, so use them only when there's no suitable HTML element.
Landmark roles
Landmarks let users jump between major sections. Prefer native tags (<header>, <nav>, <main>, etc.) because they already have implicit roles. Add an explicit role only when you can't use the semantic element.
| 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
When you build a component that has no native HTML counterpart, give it a role that matches its purpose.
| 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.
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 attributes are the ones you'll reach for most often.
aria-label
Use when there is no visible label. Perfect for icon-only buttons or inputs that only have a placeholder.
// 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 an element whose text is the label. Prefer this when a visible label already exists because it keeps the label consistent with what sighted users see.
// 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>
<span id="item-name">Project Alpha</span>
<!-- Screen reader announces: "Delete Project Alpha" -->
aria-describedby
Provides additional description that follows the name. Use for help text, error messages, or any supplemental info.
// 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 while keeping it visible. Good for decorative icons or background images. Never hide interactive controls.
// 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 you'll need
When two elements are related but not nested, connect them with the following attributes.
-
aria-controls: identifies the element that a button (or similar) controls. -
aria-owns: tells assistive tech that an element logically owns another element that lives elsewhere in the DOM. -
aria-describedbyandaria-labelledby(covered above) also act as relationship hooks.
Gotcha: aria-controls does not automatically hide or show the target element. You still need to manage visibility with hidden, CSS, or React state. The attribute is only a hint for screen readers about the relationship.
That's the core set of ARIA attributes you'll use in a typical React/Next.js UI. Copy the snippets, adjust the IDs and state variables to match your component, and you'll have accessible widgets without pulling in a heavyweight library.
This is a shorter version of a guide I wrote on thefrontkit. The full post has the complete walkthrough.
Top comments (0)