ARIA
Roles, states, properties, live regions, and practical patterns for WAI-ARIA
ARIA
WAI-ARIA (Accessible Rich Internet Applications) bridges the gap between complex UI components and assistive technologies. Use it to communicate roles, states, and dynamic changes that native HTML cannot express.
ARIA Fundamentals
The Five Rules of ARIA
- Don't use ARIA if native HTML works —
<button>beats<div role="button"> - Don't change native semantics — don't add
role="heading"to a<button> - All interactive ARIA controls must be keyboard operable
- Don't use
role="presentation"oraria-hidden="true"on focusable elements - All interactive elements must have an accessible name
How Screen Readers Use ARIA
Screen Reader Announcement Components
├── Role → What it is ("button", "checkbox", "tab")
├── Name → What it's called ("Submit", "Dark mode", "Settings")
├── State → Current condition ("expanded", "checked", "selected")
└── Value → Current value ("3 of 5", "50%")
Example: "Submit, button"
Example: "Dark mode, switch, on"
Example: "Volume, slider, 75%"Roles
Widget Roles
| Role | Use Case | Native Equivalent |
|---|---|---|
button | Clickable action trigger | <button> |
link | Navigation to a resource | <a href> |
checkbox | Binary toggle | <input type="checkbox"> |
radio | One-of-many selection | <input type="radio"> |
switch | On/off toggle | None (use ARIA) |
slider | Range value selector | <input type="range"> |
textbox | Text input | <input type="text"> |
combobox | Input with popup list | <select> with search |
listbox | Selection list | <select> |
menu | Action menu (not navigation) | None (use ARIA) |
menuitem | Item in a menu | None (use ARIA) |
tab | Tab in a tab list | None (use ARIA) |
tabpanel | Content panel for a tab | None (use ARIA) |
dialog | Modal or non-modal dialog | <dialog> |
alertdialog | Alert requiring acknowledgment | None (use ARIA) |
progressbar | Progress indicator | <progress> |
tooltip | Hover/focus tooltip | None (use ARIA) |
Composite Widget Patterns
// Tabs pattern
<div role="tablist" aria-label="Account settings">
<button role="tab" aria-selected={activeTab === 'profile'} aria-controls="panel-profile"
id="tab-profile" onClick={() => setActiveTab('profile')}>
Profile
</button>
<button role="tab" aria-selected={activeTab === 'security'} aria-controls="panel-security"
id="tab-security" onClick={() => setActiveTab('security')}>
Security
</button>
</div>
<div role="tabpanel" id="panel-profile" aria-labelledby="tab-profile"
hidden={activeTab !== 'profile'}>
Profile settings content
</div>
<div role="tabpanel" id="panel-security" aria-labelledby="tab-security"
hidden={activeTab !== 'security'}>
Security settings content
</div>Document Structure Roles
| Role | Purpose |
|---|---|
banner | Site header (implicit on <header>) |
navigation | Navigation section (implicit on <nav>) |
main | Primary content (implicit on <main>) |
complementary | Supporting content (implicit on <aside>) |
contentinfo | Site footer (implicit on <footer>) |
region | Significant section (requires a label) |
search | Search functionality (implicit on <search>) |
States and Properties
Common States
// Expandable section
<button
aria-expanded={isOpen}
aria-controls="section-content"
onClick={() => setIsOpen(!isOpen)}
>
{isOpen ? 'Collapse' : 'Expand'} Section
</button>
<div id="section-content" hidden={!isOpen}>
Expandable content here
</div>
// Toggle switch
<button
role="switch"
aria-checked={isDarkMode}
onClick={() => setIsDarkMode(!isDarkMode)}
>
Dark Mode
</button>
// Disabled state
<button aria-disabled="true" onClick={(e) => {
// aria-disabled doesn't prevent click, so guard it
if (isSubmitting) return;
handleSubmit();
}}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>aria-disabled vs disabled. Use aria-disabled="true" when you want the element to remain focusable (so screen reader users can discover it) but prevent interaction. The native disabled attribute removes the element from tab order entirely.
Property Reference
| Property | Purpose | Example |
|---|---|---|
aria-label | Provides accessible name directly | aria-label="Close dialog" |
aria-labelledby | References element(s) that label this | aria-labelledby="heading-1" |
aria-describedby | References element(s) that describe this | aria-describedby="password-hint" |
aria-controls | References element this controls | aria-controls="dropdown-menu" |
aria-owns | Defines parent-child relationship in DOM | aria-owns="popup-list" |
aria-required | Indicates required input | aria-required="true" |
aria-invalid | Indicates validation error | aria-invalid="true" |
aria-haspopup | Indicates popup availability | aria-haspopup="listbox" |
aria-current | Indicates current item | aria-current="page" |
aria-busy | Indicates section is loading | aria-busy="true" |
Naming Techniques
// 1. aria-label — when no visible label exists
<button aria-label="Close">
<XIcon />
</button>
// 2. aria-labelledby — reference visible text
<h2 id="billing-heading">Billing Address</h2>
<form aria-labelledby="billing-heading">
{/* form fields */}
</form>
// 3. aria-labelledby with multiple sources
<div role="dialog" aria-labelledby="dialog-title dialog-subtitle">
<h2 id="dialog-title">Confirm Deletion</h2>
<p id="dialog-subtitle">This action cannot be undone</p>
</div>
// 4. aria-describedby — supplementary description
<label htmlFor="password">Password</label>
<input id="password" type="password" aria-describedby="password-help" />
<p id="password-help">Must be at least 8 characters with one number.</p>Live Regions
Live regions announce dynamic content changes to screen reader users.
Politeness Levels
| Level | Behavior | Use Case |
|---|---|---|
aria-live="polite" | Waits for screen reader to finish current speech | Status messages, notifications |
aria-live="assertive" | Interrupts immediately | Errors, urgent alerts |
role="status" | Implicit aria-live="polite" | Form feedback, counters |
role="alert" | Implicit aria-live="assertive" | Error messages |
role="log" | Implicit aria-live="polite" | Chat, activity logs |
Implementation Patterns
// Status message — polite announcement
function SearchResults({ count }: { count: number }) {
return (
<div role="status" aria-live="polite">
{count} results found
</div>
);
}
// Error alert — assertive announcement
function FormError({ message }: { message: string }) {
return (
<div role="alert">
{message}
</div>
);
}
// Toast notification system
function ToastContainer({ toasts }: { toasts: Toast[] }) {
return (
<div aria-live="polite" aria-relevant="additions">
{toasts.map((toast) => (
<div key={toast.id} role="status">
{toast.message}
</div>
))}
</div>
);
}
// Loading state with aria-busy
function DataTable({ isLoading, data }: DataTableProps) {
return (
<div aria-busy={isLoading} aria-live="polite">
{isLoading ? (
<p>Loading data...</p>
) : (
<table>{/* table content */}</table>
)}
</div>
);
}Live region must exist in DOM before content changes. Render the container element on initial load (can be empty), then update its content. If the live region itself is dynamically inserted, screen readers may miss the announcement.
Hiding Content
Techniques Comparison
| Technique | Visual | Screen Reader | Focus |
|---|---|---|---|
aria-hidden="true" | Visible | Hidden | Must not be focusable |
display: none | Hidden | Hidden | Not focusable |
visibility: hidden | Hidden (keeps space) | Hidden | Not focusable |
.sr-only (visually hidden) | Hidden | Accessible | Focusable |
hidden attribute | Hidden | Hidden | Not focusable |
/* Visually hidden but accessible to screen readers */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}// Decorative icon — hide from screen readers
<button>
<SearchIcon aria-hidden="true" />
Search
</button>
// Icon-only button — provide text for screen readers
<button aria-label="Search">
<SearchIcon aria-hidden="true" />
</button>
// Visually hidden text for additional context
<a href="/blog">
Read more<span className="sr-only"> about accessibility testing</span>
</a>Common ARIA Patterns
Disclosure (Show/Hide)
function Disclosure({ title, children }: DisclosureProps) {
const [isOpen, setIsOpen] = useState(false);
const contentId = useId();
return (
<div>
<button
aria-expanded={isOpen}
aria-controls={contentId}
onClick={() => setIsOpen(!isOpen)}
>
{title}
</button>
<div id={contentId} hidden={!isOpen}>
{children}
</div>
</div>
);
}Combobox (Autocomplete)
function Combobox({ options, onSelect }: ComboboxProps) {
const [query, setQuery] = useState('');
const [isOpen, setIsOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(-1);
const listboxId = useId();
const filtered = options.filter((o) =>
o.label.toLowerCase().includes(query.toLowerCase())
);
return (
<div>
<input
role="combobox"
aria-expanded={isOpen}
aria-controls={listboxId}
aria-activedescendant={activeIndex >= 0 ? `option-${activeIndex}` : undefined}
aria-autocomplete="list"
value={query}
onChange={(e) => {
setQuery(e.target.value);
setIsOpen(true);
}}
onKeyDown={(e) => {
if (e.key === 'ArrowDown') {
setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
} else if (e.key === 'ArrowUp') {
setActiveIndex((i) => Math.max(i - 1, 0));
} else if (e.key === 'Enter' && activeIndex >= 0) {
onSelect(filtered[activeIndex]);
setIsOpen(false);
} else if (e.key === 'Escape') {
setIsOpen(false);
}
}}
/>
{isOpen && (
<ul role="listbox" id={listboxId}>
{filtered.map((option, index) => (
<li
key={option.value}
role="option"
id={`option-${index}`}
aria-selected={index === activeIndex}
onClick={() => {
onSelect(option);
setIsOpen(false);
}}
>
{option.label}
</li>
))}
</ul>
)}
</div>
);
}Best Practices
ARIA Usage Guidelines
- Prefer native HTML elements over ARIA roles
- Every interactive element needs an accessible name
- Keep
aria-liveregions in the DOM from page load - Use
aria-hidden="true"for decorative elements only - Never put
aria-hiddenon focusable elements - Test with screen readers — automated tools miss ARIA issues
- Use
aria-current="page"for current navigation item - Match ARIA states to visual states — don't let them drift apart