Skip to content
Back to Quality Assurance

ARIA Patterns

Use ARIA roles, states, and properties correctly for custom widgets that semantic HTML cannot express.

Why

Semantic HTML covers most needs (button, nav, main, form, table), but custom widgets — tabs, accordions, comboboxes, tree views, menus — have no native equivalent. ARIA (Accessible Rich Internet Applications) fills this gap by adding roles (tab, tablist, tabpanel), states (aria-selected, aria-expanded), and properties (aria-controls, aria-labelledby) that tell the screen reader what the widget is and how it behaves. Used correctly, ARIA makes a custom tab widget indistinguishable from a native one. Used incorrectly, it makes the widget worse than no ARIA — a role="button" on a div that is not keyboard-accessible tells the screen reader "this is a button" but the user cannot activate it, which is more frustrating than an unlabeled div. The first rule of ARIA is "no ARIA is better than bad ARIA" — use it only when semantic HTML is insufficient, and use it correctly.

When

Only when semantic HTML is insufficient — custom tabs, accordions, comboboxes, menus, tree views, live regions. If a native element exists (button, select, details/summary), use it instead of ARIA.

How

1. Check if a native element exists before using ARIA
   - Tabs → native <details>/<summary> for simple cases, ARIA for complex
   - Dropdown → native <select> if it is a simple list, ARIA combobox for search
   - Dialog → native <dialog> element (if browser support is sufficient)
   - If native works, use it — it is keyboard-accessible and screen-reader-announced for free

2. Follow the ARIA Authoring Practices Guide (APG) patterns
   - https://www.w3.org/WAI/ARIA/apg/
   - Each pattern specifies: roles, keyboard interaction, aria properties
   - Do not invent your own ARIA pattern — use the documented one

3. For a tab widget (the most common custom widget):
   - role="tablist" on the container, role="tab" on each tab, role="tabpanel" on each panel
   - aria-selected="true/false" on the active tab
   - aria-controls="panel-id" on the tab, aria-labelledby="tab-id" on the panel
   - Keyboard: Arrow Left/Right to move between tabs, Tab to enter the panel

4. For an accordion:
   - Use native <details>/<summary> if possible (no ARIA needed, works by default)
   - If custom: role="region" on the panel, aria-expanded on the trigger, aria-controls

5. For a combobox (searchable dropdown):
   - role="combobox" on the input, role="listbox" on the results, role="option" on each
   - aria-expanded="true/false" on the input, aria-controls="listbox-id"
   - aria-activedescendant="option-id" for the highlighted option
   - Keyboard: Arrow Down/Up to move, Enter to select, Escape to close

6. Test with a screen reader after implementing
   - Does the widget announce its role and state correctly?
   - Does the keyboard interaction match the APG pattern?
   - Is the widget usable without any visual information?

Example

// Tab widget: follow the ARIA APG pattern
function Tabs({ tabs }) {
  const [activeIndex, setActiveIndex] = useState(0);
  return (
    <div>
      <div role="tablist">
        {tabs.map((tab, i) => (
          <button
            key={tab.id}
            role="tab"
            id={`tab-${tab.id}`}
            aria-selected={i === activeIndex}
            aria-controls={`panel-${tab.id}`}
            tabIndex={i === activeIndex ? 0 : -1}
            onClick={() => setActiveIndex(i)}
            onKeyDown={handleTabKeyDown} // Arrow Left/Right
          >
            {tab.label}
          </button>
        ))}
      </div>
      {tabs.map((tab, i) => (
        <div
          key={tab.id}
          role="tabpanel"
          id={`panel-${tab.id}`}
          aria-labelledby={`tab-${tab.id}`}
          hidden={i !== activeIndex}
        >
          {tab.content}
        </div>
      ))}
    </div>
  );
}

// Accordion: prefer native <details> (no ARIA needed)
<details>
  <summary>Section title</summary>
  <div>Section content</div>
</details>
// Works with keyboard, screen reader, and expand/collapse for free

// Combobox: searchable dropdown
function Combobox({ options, value, onChange }) {
  const [isOpen, setIsOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(-1);
  return (
    <div>
      <input
        role="combobox"
        aria-expanded={isOpen}
        aria-controls="combobox-listbox"
        aria-activedescendant={activeIndex >= 0 ? `option-${activeIndex}` : undefined}
        value={value}
        onKeyDown={handleComboboxKeyDown} // Arrow Down/Up/Enter/Escape
      />
      {isOpen && (
        <ul role="listbox" id="combobox-listbox">
          {options.map((opt, i) => (
            <li
              key={opt.id}
              role="option"
              id={`option-${i}`}
              aria-selected={i === activeIndex}
              onClick={() => onChange(opt)}
            >
              {opt.label}
            </li>
          ))}
        </ul>
      )}
    </div>
  );
}

Common Problems

  • ⚠Adding role="button" to a <div onClick> without also making it keyboard-accessible — the screen reader announces "button" but the user cannot Tab to it or activate it, which is worse than no ARIA.
  • ⚠Using ARIA roles without the corresponding keyboard interaction — a role="tab" widget that does not support Arrow Left/Right is announced as tabs but is not operable as tabs, confusing the screen reader user.
  • ⚠Inventing custom ARIA roles or attributes instead of following the APG patterns — the custom ARIA is not understood by any screen reader, and the widget is less accessible than if no ARIA were used.
  • ⚠Using ARIA to "fix" a div when a native element would work — adding role="navigation" and aria-label to a <div> when a <nav> element provides the same semantics for free and is keyboard-accessible by default.

Best Practices

  • ✓Use native HTML elements (button, nav, main, details, select) whenever they exist — they provide the role, keyboard support, and screen reader announcement for free, and ARIA is only needed for custom widgets.
  • ✓Follow the ARIA Authoring Practices Guide (APG) for every custom widget — the patterns are tested with real screen readers and specify the exact roles, states, properties, and keyboard interaction.
  • ✓Always pair ARIA roles with the corresponding keyboard interaction — a role="tab" must support Arrow Left/Right, a role="combobox" must support ArrowDown/Enter/Escape, or the widget is announced as something it is not.
  • ✓Test every custom widget with at least two screen readers (NVDA + VoiceOver) — ARIA interpretation varies, and a widget that works in one may be broken in the other.