ARIA Roles, States, and Live Regions
ARIA Is a Bridge, Not a Foundation
Here's the thing most people miss about ARIA: it doesn't do anything. It doesn't add keyboard behavior. It doesn't change how elements look or work. All it does is change what the accessibility tree tells assistive technology about your markup.
That's powerful when you need it. But it's also dangerous when you don't.
The first rule of ARIA is literally: don't use ARIA. If there's a native HTML element that does what you need, use it. A button element already has role="button", keyboard support, focus handling, and click events. Adding role="button" to a div gives you exactly one of those things: the role. You still have to build the other three yourself.
Think of ARIA as subtitles for your UI. A sighted user watches the movie (your visual interface) and understands what's happening. A screen reader user reads the subtitles (the accessibility tree). ARIA lets you write better subtitles when the automatic ones are wrong or missing. But if you write subtitles that don't match what's actually happening on screen, you'll confuse everyone reading them.
The Five Role Categories
ARIA defines over 80 roles, but they fall into five categories. You don't need to memorize every role. You need to understand the categories and know the ones you'll actually use.
Widget Roles You'll Actually Use
Most of the roles you write by hand are widget roles, because most native HTML elements already cover document structure and landmarks. Here are the widget roles that come up in real codebases:
| Role | What It Represents | When You Need It |
|---|---|---|
| tab / tabpanel / tablist | A tabbed interface with panels | Custom tab components not using native elements |
| dialog | A modal or popup overlay | Any overlay that requires user attention or interaction |
| combobox | An input with a popup list of options | Autocomplete, searchable dropdowns, command palettes |
| listbox / option | A selectable list of items | Custom select menus, multi-select components |
| switch | A binary on/off toggle | Toggle switches that aren't checkboxes (different semantic: on/off vs checked/unchecked) |
| menu / menuitem | An application menu with actions | Context menus, dropdown action menus (not navigation lists) |
| tree / treeitem | A hierarchical expandable list | File browsers, nested navigation, folder structures |
| alertdialog | A dialog requiring acknowledgment | Confirmation dialogs before destructive actions |
The menu role is for application menus with actions (like a right-click context menu), not for site navigation dropdowns. Navigation uses nav with a list of links. Using role="menu" for navigation confuses screen reader users because they expect menuitem-level keyboard behavior (arrow keys, typeahead, first-letter navigation).
The Tab Pattern in Detail
Tabs are one of the most commonly built custom widgets, and one of the most commonly broken. Here's what the accessibility tree expects:
<div role="tablist" aria-label="Account settings">
<button role="tab" id="tab-1" aria-selected="true" aria-controls="panel-1">
Profile
</button>
<button role="tab" id="tab-2" aria-selected="false" aria-controls="panel-2" tabindex="-1">
Security
</button>
<button role="tab" id="tab-3" aria-selected="false" aria-controls="panel-3" tabindex="-1">
Notifications
</button>
</div>
<div role="tabpanel" id="panel-1" aria-labelledby="tab-1">
<!-- Profile content -->
</div>
<div role="tabpanel" id="panel-2" aria-labelledby="tab-2" hidden>
<!-- Security content -->
</div>
<div role="tabpanel" id="panel-3" aria-labelledby="tab-3" hidden>
<!-- Notifications content -->
</div>
The key details here:
- Only the active tab is in the Tab order (
tabindex="-1"on inactive tabs) - Arrow keys move between tabs within the tablist
aria-selected="true"marks the active tabaria-controlslinks each tab to its panelaria-labelledbylinks each panel back to its tab
States and Properties That Matter
ARIA attributes fall into two groups: states (values that change during interaction) and properties (values that are generally static). In practice, the distinction doesn't matter much. What matters is using the right attribute for the right purpose.
The States You'll Use Every Week
Here are the ARIA states that show up in almost every component library:
aria-expanded signals that a control opens or closes related content:
<button aria-expanded="false" aria-controls="dropdown-menu">
Options
</button>
<ul id="dropdown-menu" hidden>
<li>Edit</li>
<li>Delete</li>
</ul>
When the button is clicked and the menu opens, flip aria-expanded to "true" and remove hidden. Screen readers announce "Options, button, collapsed" or "Options, button, expanded" so users know there's content they can reveal.
aria-selected marks the currently chosen item in a selection context:
<div role="listbox" aria-label="Choose a color">
<div role="option" aria-selected="true">Red</div>
<div role="option" aria-selected="false">Blue</div>
<div role="option" aria-selected="false">Green</div>
</div>
Use aria-selected on tabs, listbox options, and grid cells. Don't confuse it with aria-checked or aria-pressed, which serve different roles.
aria-checked is for checkboxes and radio buttons:
<div role="checkbox" aria-checked="mixed" tabindex="0">
Select all
</div>
Notice the "mixed" value. This is the indeterminate state when some but not all children are checked. Native checkboxes support this visually via the indeterminate property, but ARIA is how you communicate it to the accessibility tree.
aria-pressed makes a button a toggle:
<button aria-pressed="false">
Mute
</button>
A pressed button has two states: pressed and not pressed. Screen readers announce "Mute, toggle button, not pressed" so users understand this button stays active until toggled again. This is different from aria-checked, which implies a form value being submitted.
aria-current indicates the current item in a set:
<nav aria-label="Breadcrumb">
<ol>
<li><a href="/courses">Courses</a></li>
<li><a href="/courses/frontend">Frontend</a></li>
<li><a href="/courses/frontend/a11y" aria-current="page">Accessibility</a></li>
</ol>
</nav>
Valid values are page, step, location, date, time, and true. Screen readers announce "Accessibility, current page, link" so users know where they are.
aria-disabled marks a control as visible but not operable:
<button aria-disabled="true">
Submit
</button>
The native disabled attribute removes the element from the Tab order entirely, making it invisible to keyboard users. aria-disabled keeps the element focusable so users can discover it and understand why it's unavailable (via a tooltip or adjacent text). Use aria-disabled when you want the user to know the option exists but isn't available yet. Use native disabled when the element is irrelevant to the current context.
| Attribute | Purpose | Used On | Values |
|---|---|---|---|
| aria-expanded | Content can be shown/hidden | Buttons, links that toggle content | true / false |
| aria-selected | Item is chosen in a selection | Tabs, listbox options, grid cells | true / false |
| aria-checked | Checkbox/radio state | Checkboxes, switches, radio buttons | true / false / mixed |
| aria-pressed | Toggle button state | Toggle buttons | true / false / mixed |
| aria-current | Current item in a set | Navigation links, breadcrumbs, steps | page / step / location / date / time / true |
| aria-disabled | Present but not operable | Any interactive element | true / false |
Live Regions: Announcing Dynamic Content
Here's where ARIA becomes genuinely magical. When content on a page changes dynamically (an error message appears, a notification pops up, a counter updates), sighted users see it immediately. But a screen reader user has no idea something changed unless you tell them.
Live regions solve this. When you mark an element as a live region, the screen reader monitors it. Whenever its content changes, the screen reader interrupts (or waits, depending on politeness) to announce the new content.
aria-live: Polite vs Assertive
<!-- Polite: waits until the user is idle to announce -->
<div aria-live="polite">
3 results found
</div>
<!-- Assertive: interrupts whatever the screen reader is saying -->
<div aria-live="assertive">
Error: Password must be at least 8 characters
</div>
Polite is the right choice 90% of the time. It queues the announcement and delivers it when the screen reader finishes its current task. Use it for search results, status updates, character counts, and non-critical feedback.
Assertive interrupts immediately. Use it sparingly: error messages that block progress, session timeout warnings, and alerts that require immediate attention. Overusing assertive is like a coworker who taps your shoulder every 30 seconds. It's technically effective but deeply annoying.
Off (aria-live="off") is the default. The region exists but doesn't announce changes.
The alert and status Shorthand Roles
Instead of manually setting aria-live, you can use semantic roles that include live region behavior:
<!-- role="alert" = aria-live="assertive" + aria-atomic="true" -->
<div role="alert">
Your session expires in 2 minutes.
</div>
<!-- role="status" = aria-live="polite" + aria-atomic="true" -->
<div role="status">
File uploaded successfully.
</div>
role="alert" is shorthand for an assertive live region. role="status" is shorthand for a polite one. Both include aria-atomic="true" by default, which means the entire content of the region is announced, not just the part that changed.
| Approach | Politeness | Atomic | Best For |
|---|---|---|---|
| aria-live='polite' | Waits for idle | No (default) | Search results, counters, non-critical status updates |
| aria-live='assertive' | Interrupts immediately | No (default) | Critical errors, time-sensitive warnings |
| role='status' | Polite (built-in) | Yes (built-in) | Success messages, progress feedback |
| role='alert' | Assertive (built-in) | Yes (built-in) | Error messages, urgent notifications |
| role='log' | Polite (built-in) | No (built-in) | Chat messages, activity feeds, console output |
aria-atomic: All or Part?
When a live region updates, should the screen reader announce only the changed text, or the entire region?
<!-- Without aria-atomic: only the changed part is announced -->
<div aria-live="polite">
<span>Score:</span>
<span>42</span> <!-- Only "42" is announced when this changes -->
</div>
<!-- With aria-atomic="true": the whole region is announced -->
<div aria-live="polite" aria-atomic="true">
<span>Score:</span>
<span>42</span> <!-- "Score: 42" is announced when this changes -->
</div>
Without aria-atomic, a screen reader might announce just "42" when the score changes. That's meaningless without context. With aria-atomic="true", it announces "Score: 42", which makes sense on its own.
Use aria-atomic="true" when the updated content doesn't make sense without its surrounding context.
aria-relevant: What Kind of Changes?
The aria-relevant attribute controls which types of changes trigger an announcement:
additionsannounces when new nodes are addedremovalsannounces when nodes are removedtextannounces when text content changesallannounces additions, removals, and text changes
The default is additions text, which covers most use cases. You rarely need to change this.
<!-- Announce when items are added or removed from a todo list -->
<ul aria-live="polite" aria-relevant="additions removals">
<li>Buy groceries</li>
<li>Walk the dog</li>
</ul>
The Toast Notification Pattern
Toasts are everywhere. They pop up to confirm an action, report an error, or show a status update. Getting them right for accessibility means combining live regions, focus management, and timing.
Here's the pattern:
<!-- Toast container: always in the DOM, content changes dynamically -->
<div
class="toast-container"
role="status"
aria-live="polite"
aria-atomic="true"
>
<!-- Toast content injected here when triggered -->
</div>
The critical rule: the live region container must exist in the DOM before the content changes. If you dynamically inject both the container and the content at the same time, many screen readers won't announce it. The container sits empty in the DOM, and you insert content into it when a toast fires.
function showToast(message, type = 'info') {
const container = document.querySelector('.toast-container');
container.textContent = message;
container.className = `toast-container toast-${type}`;
setTimeout(() => {
container.textContent = '';
}, 5000);
}
Toast Accessibility Rules
- Non-error toasts use
role="status"(polite). Success confirmations, info messages. - Error toasts use
role="alert"(assertive). The user needs to know something failed. - Don't move focus to the toast. Toasts are supplementary information. Moving focus disrupts whatever the user was doing.
- If the toast has actions (like "Undo"), provide keyboard access. Either let users navigate to the toast or offer the action through another channel.
- Auto-dismiss timing must be generous. WCAG 2.1 success criterion 2.2.1 requires users can extend, adjust, or turn off time limits. 5 seconds is usually too short for screen reader users to hear and process the message. Consider 8-10 seconds minimum, or don't auto-dismiss at all.
- Don't stack too many toasts. If three toasts fire in rapid succession, a screen reader tries to announce all three, which becomes an unintelligible mess. Queue them or consolidate.
Why inserting both the container and content simultaneously fails
Screen readers detect live region changes by observing mutations to elements already marked with aria-live (or an implicit live role like alert or status). If you create a new element with role="alert" and text content in a single DOM operation, the screen reader sees a new element appear but may not treat the initial content as a "change" because the element was never empty in the first place. Different screen readers handle this differently: NVDA tends to announce it, VoiceOver on macOS is more inconsistent, and JAWS depends on the timing. The reliable pattern is to always have the container pre-existing and empty, then update its content.
The Five Rules of ARIA
The W3C defines five rules for using ARIA. These aren't suggestions. Violating them creates accessibility bugs that are worse than having no ARIA at all.
- 1Don't use ARIA if a native HTML element provides the semantics and behavior you need
- 2Don't change native semantics unless you truly have to (don't put role='heading' on a button)
- 3All interactive ARIA controls must be keyboard operable
- 4Don't use role='presentation' or aria-hidden='true' on focusable elements
- 5All interactive elements must have an accessible name (via content, aria-label, or aria-labelledby)
That first rule is the most important one, and the most frequently ignored. Every time you reach for an ARIA attribute, ask yourself: is there a native HTML element that already does this? If yes, use that instead.
<!-- Bad: reinventing the button -->
<div role="button" tabindex="0" onclick="submit()"
onkeydown="if(event.key==='Enter'||event.key===' ')submit()">
Submit
</div>
<!-- Good: just use a button -->
<button onclick="submit()">Submit</button>
The native button gives you role, keyboard handling, focus management, form submission, and disabled state for free. The ARIA version requires you to manually build all of that.
Common Patterns That Trip People Up
aria-hidden vs role="presentation" vs hidden
These three look similar but do completely different things:
| Attribute | Visible on screen? | In accessibility tree? | Focusable? |
|---|---|---|---|
| hidden (HTML attribute) | No | No | No |
| aria-hidden='true' | Yes | No (removed from tree) | Dangerous if focusable children exist |
| role='presentation' / role='none' | Yes | Semantics removed, children may keep theirs | Depends on the element |
The danger zone is aria-hidden="true" on a container that has focusable children. A screen reader user can Tab into the container and land on a button that the accessibility tree pretends doesn't exist. They can activate it but get no feedback about what it does. The HTML inert attribute is the safer choice for hiding interactive containers because it removes both visibility to assistive tech and focusability.
When to Use aria-label vs aria-labelledby vs aria-describedby
aria-labelprovides an accessible name as a string. Use it when there's no visible text to reference.aria-labelledbypoints to another element whose text becomes the accessible name. Use it when the label already exists somewhere on the page.aria-describedbyprovides supplementary information. The name is announced first, then the description after a pause.
<!-- aria-label: no visible label exists -->
<button aria-label="Close dialog">
<svg><!-- X icon --></svg>
</button>
<!-- aria-labelledby: visible heading serves as the label -->
<section aria-labelledby="section-title">
<h2 id="section-title">Recent Activity</h2>
<!-- section content -->
</section>
<!-- aria-describedby: extra context beyond the name -->
<input
type="password"
aria-label="Password"
aria-describedby="password-hint"
/>
<p id="password-hint">Must be at least 8 characters with one number</p>
| What developers do | What they should do |
|---|---|
| Using role='menu' for site navigation dropdowns role='menu' triggers application menu behavior in screen readers (arrow key navigation, typeahead). Navigation links should be navigable with Tab like any other links. | Use nav with a ul/li/a list structure for navigation |
| Adding aria-label to elements that already have visible text aria-label overrides visible text in the accessibility tree. If the label says 'Close' but aria-label says 'Dismiss modal', screen reader users hear one thing while sighted users see another. This creates a disconnect. | Let the visible text serve as the accessible name, or use aria-labelledby |
| Using aria-live='assertive' for every notification Assertive interrupts the screen reader immediately, disrupting whatever the user is currently reading. For most notifications, polite is correct since the message can wait until the user is idle. | Use aria-live='polite' for non-critical updates, assertive only for errors and urgent messages |
| Injecting a live region and its content into the DOM simultaneously Screen readers monitor existing live regions for changes. If the region element itself is new, some screen readers won't detect the initial content as a change and won't announce it. | Keep the live region container in the DOM at all times, update only its content |
| Using aria-hidden='true' on a container with focusable children aria-hidden removes elements from the accessibility tree but not from the tab order. Users can Tab into hidden elements and interact with controls they can't identify. The inert attribute handles both. | Use the inert attribute instead, or ensure no focusable elements exist inside |
Testing Live Regions
Live regions are notoriously hard to test because behavior varies across screen readers. Here's a practical testing matrix:
- VoiceOver on macOS (Safari): Generally reliable. Test with Cmd+F5 to toggle.
- NVDA on Windows (Firefox or Chrome): Free, widely used. The most common screen reader for web testing.
- JAWS on Windows (Chrome): The enterprise standard. Handles live regions differently than NVDA.
The key things to verify:
- Polite announcements don't interrupt active reading
- Assertive announcements do interrupt when they should
aria-atomicregions announce the full content, not fragments- Rapidly changing content doesn't produce an overwhelming stream of announcements
- Toast messages give users enough time to hear and process them
Putting It All Together
ARIA is a tool with a very specific purpose: telling assistive technology things that HTML alone can't express. The best ARIA is the ARIA you don't need to write because you used semantic HTML. When you do need it, be precise: the right role, the right state, the right live region politeness. Every ARIA attribute is a promise to the user about how your interface behaves. Break that promise, and you've made the experience worse, not better.
- 1Prefer native HTML elements over ARIA roles whenever possible
- 2Every ARIA role comes with expected keyboard behavior that you must implement
- 3Use aria-live='polite' by default, assertive only for critical errors and urgent warnings
- 4Live region containers must exist in the DOM before content is injected into them
- 5aria-hidden='true' does not prevent keyboard focus, use inert for that
- 6Test with at least two screen readers because live region behavior varies significantly