Skip to content

ARIA Roles, States, and Live Regions

intermediate18 min read

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.

Mental Model

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:

RoleWhat It RepresentsWhen You Need It
tab / tabpanel / tablistA tabbed interface with panelsCustom tab components not using native elements
dialogA modal or popup overlayAny overlay that requires user attention or interaction
comboboxAn input with a popup list of optionsAutocomplete, searchable dropdowns, command palettes
listbox / optionA selectable list of itemsCustom select menus, multi-select components
switchA binary on/off toggleToggle switches that aren't checkboxes (different semantic: on/off vs checked/unchecked)
menu / menuitemAn application menu with actionsContext menus, dropdown action menus (not navigation lists)
tree / treeitemA hierarchical expandable listFile browsers, nested navigation, folder structures
alertdialogA dialog requiring acknowledgmentConfirmation dialogs before destructive actions
menu is not for navigation

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).

Quiz
You need to build a dropdown that shows a list of links to different pages on your site. Which approach is correct?

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 tab
  • aria-controls links each tab to its panel
  • aria-labelledby links each panel back to its tab
Quiz
In a properly implemented tab pattern, how does a keyboard user move between tabs?

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>
aria-disabled vs the disabled attribute

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.

AttributePurposeUsed OnValues
aria-expandedContent can be shown/hiddenButtons, links that toggle contenttrue / false
aria-selectedItem is chosen in a selectionTabs, listbox options, grid cellstrue / false
aria-checkedCheckbox/radio stateCheckboxes, switches, radio buttonstrue / false / mixed
aria-pressedToggle button stateToggle buttonstrue / false / mixed
aria-currentCurrent item in a setNavigation links, breadcrumbs, stepspage / step / location / date / time / true
aria-disabledPresent but not operableAny interactive elementtrue / false
Quiz
You have a 'Bookmark' button that stays active after clicking (like a toggle). Which ARIA attribute best communicates this behavior?

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.

ApproachPolitenessAtomicBest For
aria-live='polite'Waits for idleNo (default)Search results, counters, non-critical status updates
aria-live='assertive'Interrupts immediatelyNo (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:

  • additions announces when new nodes are added
  • removals announces when nodes are removed
  • text announces when text content changes
  • all announces 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>
Quiz
You're building a character counter that shows '140 characters remaining' below a textarea. Which live region setup is best?

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

  1. Non-error toasts use role="status" (polite). Success confirmations, info messages.
  2. Error toasts use role="alert" (assertive). The user needs to know something failed.
  3. Don't move focus to the toast. Toasts are supplementary information. Moving focus disrupts whatever the user was doing.
  4. If the toast has actions (like "Undo"), provide keyboard access. Either let users navigate to the toast or offer the action through another channel.
  5. 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.
  6. 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.

Key Rules
  1. 1Don't use ARIA if a native HTML element provides the semantics and behavior you need
  2. 2Don't change native semantics unless you truly have to (don't put role='heading' on a button)
  3. 3All interactive ARIA controls must be keyboard operable
  4. 4Don't use role='presentation' or aria-hidden='true' on focusable elements
  5. 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.

Quiz
What's wrong with this code: a div with role='img' and an aria-label of 'Company logo', that is currently also focusable with tabindex='0'?

Common Patterns That Trip People Up

aria-hidden vs role="presentation" vs hidden

These three look similar but do completely different things:

AttributeVisible on screen?In accessibility tree?Focusable?
hidden (HTML attribute)NoNoNo
aria-hidden='true'YesNo (removed from tree)Dangerous if focusable children exist
role='presentation' / role='none'YesSemantics removed, children may keep theirsDepends 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-label provides an accessible name as a string. Use it when there's no visible text to reference.
  • aria-labelledby points to another element whose text becomes the accessible name. Use it when the label already exists somewhere on the page.
  • aria-describedby provides 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 doWhat 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:

  1. VoiceOver on macOS (Safari): Generally reliable. Test with Cmd+F5 to toggle.
  2. NVDA on Windows (Firefox or Chrome): Free, widely used. The most common screen reader for web testing.
  3. 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-atomic regions 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
Quiz
You dynamically add a div with role='alert' and error text to the page. It works in NVDA but VoiceOver on macOS sometimes doesn't announce it. What's the most likely cause?

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.

Key Rules
  1. 1Prefer native HTML elements over ARIA roles whenever possible
  2. 2Every ARIA role comes with expected keyboard behavior that you must implement
  3. 3Use aria-live='polite' by default, assertive only for critical errors and urgent warnings
  4. 4Live region containers must exist in the DOM before content is injected into them
  5. 5aria-hidden='true' does not prevent keyboard focus, use inert for that
  6. 6Test with at least two screen readers because live region behavior varies significantly