WAI-ARIA: Accessible Rich Internet Applications Standard | OpsBlu Docs

WAI-ARIA: Accessible Rich Internet Applications Standard

W3C WAI-ARIA roles, states, and properties for making dynamic web content accessible to screen readers.

What WAI-ARIA Is

WAI-ARIA (Web Accessibility Initiative — Accessible Rich Internet Applications) is a W3C specification that adds semantic meaning to HTML elements so assistive technologies like screen readers can understand dynamic web content. It fills the gap between what native HTML can express and what modern web applications actually do.

The first rule of ARIA: Don't use ARIA if native HTML does the job. A <button> is always better than <div role="button">. ARIA should enhance, not replace, semantic HTML.

Version Released Status
WAI-ARIA 1.0 March 2014 Superseded
WAI-ARIA 1.1 December 2017 Recommendation
WAI-ARIA 1.2 June 2023 Current Recommendation
WAI-ARIA 1.3 In development Working Draft

When You Need ARIA

Use ARIA when HTML alone cannot communicate the component's purpose or state to assistive technology:

Scenario Native HTML Sufficient? ARIA Needed?
Simple button Yes — <button> No
Navigation Yes — <nav> No
Modal dialog Partially — no native <dialog> state management Yes — aria-modal, focus trap
Tab panel No native element Yes — role="tablist", role="tab", role="tabpanel"
Accordion No native expand/collapse semantics Yes — aria-expanded, aria-controls
Live notification No native "content updated" signal Yes — aria-live
Combobox / autocomplete No native pattern Yes — role="combobox", aria-activedescendant
Progress indicator Partially — <progress> element exists Sometimes — aria-valuenow for custom designs

Core ARIA Concepts

Roles

Roles tell assistive technology what an element is. There are four categories:

Landmark roles define page regions. Screen reader users jump between landmarks to navigate:

<header role="banner">...</header>         <!-- Site header (use <header> instead) -->
<nav role="navigation">...</nav>           <!-- Navigation (use <nav> instead) -->
<main role="main">...</main>               <!-- Main content (use <main> instead) -->
<aside role="complementary">...</aside>    <!-- Sidebar (use <aside> instead) -->
<footer role="contentinfo">...</footer>    <!-- Site footer (use <footer> instead) -->
<form role="search">...</form>             <!-- Search form (use <search> in HTML5.2+) -->

<!-- ARIA landmark roles are redundant when using semantic HTML -->
<!-- Only add role if you can't use the semantic element -->

Widget roles describe interactive components:

<div role="tablist">
  <button role="tab" aria-selected="true" aria-controls="panel-1">Tab 1</button>
  <button role="tab" aria-selected="false" aria-controls="panel-2">Tab 2</button>
</div>
<div role="tabpanel" id="panel-1">Content for tab 1</div>
<div role="tabpanel" id="panel-2" hidden>Content for tab 2</div>

Common widget roles: tab, tablist, tabpanel, dialog, alertdialog, menu, menuitem, tree, treeitem, listbox, option, slider, spinbutton, combobox, tooltip.

Document structure roles: article, heading, list, listitem, table, row, cell, img, figure, separator.

Live region roles: alert, log, marquee, status, timer.

States and Properties

States and properties describe the current condition of an element. States change dynamically; properties are relatively static.

Common states (change with interaction):

Attribute Purpose Example
aria-expanded Accordion/dropdown open state aria-expanded="false""true" on click
aria-selected Tab or listbox selection aria-selected="true" on active tab
aria-checked Checkbox/radio state aria-checked="mixed" for indeterminate
aria-disabled Non-interactive state aria-disabled="true" (different from HTML disabled)
aria-hidden Hide from assistive technology aria-hidden="true" on decorative elements
aria-invalid Form validation failure aria-invalid="true" on error
aria-pressed Toggle button state aria-pressed="true" when active
aria-busy Content loading/updating aria-busy="true" during fetch

Common properties (relatively static):

Attribute Purpose Example
aria-label Accessible name (no visible label) <button aria-label="Close">×</button>
aria-labelledby Points to visible label element aria-labelledby="heading-id"
aria-describedby Points to descriptive text aria-describedby="help-text-id"
aria-controls Element this controls Tab → aria-controls="panel-id"
aria-owns DOM ownership (for reordered content) Parent → aria-owns="child-id"
aria-live Announce dynamic updates aria-live="polite" or "assertive"
aria-required Required form field aria-required="true"
aria-haspopup Indicates popup type aria-haspopup="menu"
aria-current Current item in a set aria-current="page" for active nav link

Common Widget Patterns

These patterns follow the WAI-ARIA Authoring Practices Guide (APG).

<div role="dialog" aria-modal="true" aria-labelledby="dialog-title">
  <h2 id="dialog-title">Confirm Deletion</h2>
  <p>Are you sure you want to delete this item?</p>
  <button>Cancel</button>
  <button>Delete</button>
</div>

Keyboard requirements:

  • Focus moves into the dialog when it opens (to the first focusable element or the dialog itself)
  • Tab/Shift+Tab cycles focus within the dialog (focus trap)
  • Escape closes the dialog
  • Focus returns to the trigger element when the dialog closes
// Focus trap for modal dialog
function trapFocus(dialog) {
  const focusable = dialog.querySelectorAll(
    'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
  );
  const first = focusable[0];
  const last = focusable[focusable.length - 1];

  dialog.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') { closeDialog(); return; }
    if (e.key !== 'Tab') return;

    if (e.shiftKey && document.activeElement === first) {
      e.preventDefault();
      last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
      e.preventDefault();
      first.focus();
    }
  });

  first.focus();
}

Accordion

<div class="accordion">
  <h3>
    <button aria-expanded="false" aria-controls="section1">
      Section 1
    </button>
  </h3>
  <div id="section1" role="region" aria-labelledby="section1-btn" hidden>
    <p>Section 1 content...</p>
  </div>
</div>

Keyboard: Enter/Space toggles the section. Optionally, arrow keys move between accordion headers.

Live Regions

Live regions announce dynamic content updates to screen readers without moving focus.

<!-- Polite: announces after screen reader finishes current speech -->
<div aria-live="polite" aria-atomic="true">
  3 items in your cart
</div>

<!-- Assertive: interrupts current speech immediately (use sparingly) -->
<div role="alert">
  Your session will expire in 2 minutes.
</div>

<!-- Status: for non-urgent status updates -->
<div role="status">
  Search found 42 results.
</div>
aria-live Value Behavior Use For
off (default) No announcements Static content
polite Waits for pause in speech Cart updates, search results count, form saved
assertive Interrupts immediately Errors, session expiry, critical alerts

Important: The live region container must exist in the DOM before you update its content. If you insert the container and content at the same time, the screen reader may not announce it.

<nav aria-label="Main navigation">
  <a href="/" aria-current="page">Home</a>
  <a href="/products">Products</a>
  <a href="/about">About</a>
</nav>

aria-current="page" tells screen readers which link represents the current page. Other values: step (multi-step process), date (calendar), time, location, true (generic).

Common ARIA Mistakes

1. Redundant ARIA on Semantic HTML

<!-- Bad: redundant -->
<button role="button">Save</button>
<nav role="navigation">...</nav>
<main role="main">...</main>

<!-- Good: native HTML is sufficient -->
<button>Save</button>
<nav>...</nav>
<main>...</main>

2. Using aria-hidden="true" on Focusable Elements

<!-- Bad: screen reader can't see it, but keyboard can still focus it -->
<button aria-hidden="true">Hidden but focusable</button>

<!-- Good: actually remove from tab order too -->
<button aria-hidden="true" tabindex="-1">Properly hidden</button>

<!-- Better: just use CSS or hidden attribute -->
<button hidden>Properly hidden</button>

3. Missing Required ARIA Properties

<!-- Bad: role="checkbox" without aria-checked -->
<div role="checkbox">Accept terms</div>

<!-- Good: required state included -->
<div role="checkbox" aria-checked="false" tabindex="0">Accept terms</div>

<!-- Best: use native HTML -->
<label><input type="checkbox"> Accept terms</label>

4. aria-label Overriding Visible Text

<!-- Bad: screen reader says "Close dialog" but visible text says "X" -->
<!-- This is actually CORRECT for icon-only buttons -->
<button aria-label="Close dialog">×</button>

<!-- Bad: aria-label contradicts visible text -->
<button aria-label="Submit form">Cancel</button>

5. Using ARIA to Fix Layout Problems

<!-- Bad: using ARIA to work around bad HTML structure -->
<div role="heading" aria-level="2">This should be an h2</div>

<!-- Good: use the actual heading element -->
<h2>This should be an h2</h2>

Testing ARIA Implementation

Screen Reader Testing

Screen Reader OS Browser Free?
VoiceOver macOS / iOS Safari Yes (built-in)
NVDA Windows Firefox, Chrome Yes
JAWS Windows Chrome, Edge No ($1,000+/year)
TalkBack Android Chrome Yes (built-in)
Narrator Windows Edge Yes (built-in)

Quick VoiceOver test (macOS): Cmd+F5 to enable. Use VO keys (Ctrl+Option) + arrow keys to navigate. Listen for role announcements ("button", "tab, selected", "expanded"), state changes, and live region updates.

Automated ARIA Validation

# axe-core catches incorrect ARIA usage
npx @axe-core/cli https://your-site.com

# Common axe ARIA rules:
# aria-allowed-attr — attribute is valid for the role
# aria-required-attr — required attributes are present
# aria-valid-attr-value — attribute values are valid
# aria-roles — role value is valid
# aria-hidden-focus — hidden elements aren't focusable

Browser DevTools

Chrome DevTools → Elements → Accessibility panel shows the computed accessible name, role, and state for any element. Firefox has a similar Accessibility Inspector (F12 → Accessibility tab) that can scan the entire page for issues.

What OpsBlu Tests

OpsBlu's accessibility audit flags ARIA-related issues including:

  • Invalid ARIA roles and attributes
  • Missing required ARIA properties for widget roles
  • aria-hidden="true" on focusable elements
  • Elements with ARIA roles but no accessible name
  • Mismatched aria-labelledby and aria-describedby references (pointing to non-existent IDs)
  • Deprecated ARIA attributes

Next Steps