Skip to main content
Saved

ARIA Roles and Attributes

Category Accessibility

Level Beginner

Examples

Native web

Use ARIA roles and attributes to provide semantic information to assistive technologies when HTML semantics are insufficient.

In this article
  1. Problem
  2. Solution
  3. Example
  4. Tab Interface with ARIA
  5. Unnecessary ARIA
  6. Live Region for Dynamic Updates
  7. Custom Combobox
  8. Dynamic State Updates
  9. Landmark Roles
  10. Toggle Button
  11. Benefits
  12. Tradeoffs
  13. Summary
  14. See also

ARIA Roles and Attributes

Problem

You’ve built a custom dropdown menu with divs and CSS. It looks good and works with a mouse, but fire up a screen reader and it says nothing useful about it. There’s no mention that it’s a menu, no indication that items are selectable, and no announcement when the selection changes.

I’ve tested interfaces where tab panels, tree views, and comboboxes were completely unusable with screen readers. Users couldn’t tell which tab was selected or understand the widget’s structure at all.

Native HTML can only express so much. There’s no <combobox> or <tabpanel> element, so when you build custom widgets, you need a way to tell assistive technologies what they are and how they behave.

Solution

ARIA tells screen readers what your custom widgets are and what they’re doing. Roles like role="menu" or role="tab" define widget types, and attributes like aria-expanded, aria-selected, and aria-checked communicate state. When the user opens your dropdown, aria-expanded changes to "true"; when they select a tab, the active one gets aria-selected="true" and the rest get "false". Screen readers use these values to tell users what each widget is and what state it’s in.

aria-label covers cases where visible text isn’t enough, aria-describedby connects elements to descriptions, and aria-controls indicates which element a button controls.

ARIA is a last resort, though. Native HTML elements like <button>, <dialog>, and <details> already have the right semantics, so ARIA is only needed when HTML can’t express what you need.

Example

Here’s ARIA in action on a dropdown menu, a tab interface, and a live region, each telling screen readers what the element is and what state it’s in.

<button
  id="settings-button"
  aria-expanded="false"
  aria-controls="settings-menu"
  aria-haspopup="menu"
>
  Settings
</button>

<ul id="settings-menu" role="menu" aria-labelledby="settings-button" hidden>
  <li role="menuitem" tabindex="-1">Profile</li>
  <li role="menuitem" tabindex="-1">Preferences</li>
  <li role="separator"></li>
  <li role="menuitem" tabindex="-1">Log out</li>
</ul>

Tab Interface with ARIA

<div role="tablist" aria-label="Account settings">
  <button role="tab" aria-selected="true" aria-controls="profile-panel" id="profile-tab" tabindex="0">
    Profile
  </button>
  <button role="tab" aria-selected="false" aria-controls="security-panel" id="security-tab" tabindex="-1">
    Security
  </button>
</div>

<div role="tabpanel" id="profile-panel" aria-labelledby="profile-tab" tabindex="0">
  <!-- Profile content -->
</div>

<div role="tabpanel" id="security-panel" aria-labelledby="security-tab" tabindex="0" hidden>
  <!-- Security content -->
</div>

Unnecessary ARIA

Native HTML elements like <button>, <nav>, <dialog>, and <select> already provide proper semantics. Adding ARIA roles to them is redundant.

Live Region for Dynamic Updates

<div role="status" aria-live="polite" aria-atomic="true" id="status-message">
  <!-- Updates announced without stealing focus -->
</div>

<script>
function updateStatus(message) {
  document.getElementById('status-message').textContent = message;
}
</script>

Custom Combobox

<label for="country-input">Choose a country</label>
<!-- aria-activedescendant is set to the highlighted option's id as the user arrows through the list -->
<input
  id="country-input"
  type="text"
  role="combobox"
  aria-autocomplete="list"
  aria-expanded="false"
  aria-controls="country-list"
/>
<ul role="listbox" id="country-list" aria-label="Countries" hidden>
  <li role="option" id="opt-1">United States</li>
  <li role="option" id="opt-2">Canada</li>
</ul>

Dynamic State Updates

A role="menu" promises screen reader users menu keyboard behavior, so the script has to provide it: focus moves into the menu when it opens, arrow keys move between items, and Escape closes it and returns focus to the button. A complete menu also handles Home, End, Enter, and type-ahead, as described in the APG menu button pattern. For a simple list of links, a disclosure (a button with aria-expanded and no menu roles) is often the better choice.

const settingsButton = document.getElementById('settings-button');
const settingsMenu = document.getElementById('settings-menu');

function toggleDropdown(button) {
  const menu = document.getElementById(button.getAttribute('aria-controls'));
  const isExpanded = button.getAttribute('aria-expanded') === 'true';

  button.setAttribute('aria-expanded', String(!isExpanded));
  menu.hidden = isExpanded;

  if (!isExpanded) {
    menu.querySelector('[role="menuitem"]').focus();
  }
}

settingsButton.addEventListener('click', () => toggleDropdown(settingsButton));

settingsMenu.addEventListener('keydown', (event) => {
  const items = [...settingsMenu.querySelectorAll('[role="menuitem"]')];
  const index = items.indexOf(document.activeElement);

  if (event.key === 'ArrowDown') {
    event.preventDefault();
    items[(index + 1) % items.length].focus();
  } else if (event.key === 'ArrowUp') {
    event.preventDefault();
    items[(index - 1 + items.length) % items.length].focus();
  } else if (event.key === 'Escape') {
    toggleDropdown(settingsButton);
    settingsButton.focus();
  }
});

function selectTab(tab) {
  const tablist = tab.closest('[role="tablist"]');

  tablist.querySelectorAll('[role="tab"]').forEach(t => {
    t.setAttribute('aria-selected', 'false');
    t.setAttribute('tabindex', '-1');
    document.getElementById(t.getAttribute('aria-controls')).hidden = true;
  });

  tab.setAttribute('aria-selected', 'true');
  tab.setAttribute('tabindex', '0');
  document.getElementById(tab.getAttribute('aria-controls')).hidden = false;
}

Landmark Roles

These roles are what <header>, <nav>, <main>, <aside>, and <footer> already give you, so the native elements are the better choice. The explicit roles are for markup you can’t change.

<div role="banner"><!-- header --></div>
<div role="navigation" aria-label="Main"><!-- nav links --></div>
<div role="main"><!-- primary content --></div>
<div role="complementary"><!-- sidebar --></div>
<div role="contentinfo"><!-- footer --></div>

Toggle Button

<button aria-pressed="false" aria-label="Dark mode" onclick="toggleDarkMode(this)">
  <span aria-hidden="true">🌙</span>
</button>

<script>
function toggleDarkMode(button) {
  const isPressed = button.getAttribute('aria-pressed') === 'true';
  // The label stays the same, and aria-pressed reports the on/off state
  button.setAttribute('aria-pressed', String(!isPressed));
}
</script>

Benefits

  • Custom widgets become usable by screen readers. Your tab panel announces as a tab panel, not as random divs.
  • Screen readers can report state, such as whether the dropdown is open or which tab is selected.
  • Complex components like comboboxes, tree views, and accordions can be built so that screen reader users can use them.
  • Relationships between elements become clear to screen reader users through attributes like aria-controls and aria-describedby.

Tradeoffs

  • Wrong ARIA is worse than no ARIA, because a misused role tells screen reader users something false about the element.
  • The specification is complex, with strict parent-child requirements (e.g., role="menuitem" needs a parent role="menu"). Role-specific attribute rules take time to learn.
  • Easy to overdo: adding role="button" to a <button> is redundant, and adding ARIA where HTML would do the job gives you more code to maintain for no benefit.
  • ARIA doesn’t add behavior. role="button" on a div makes it announce as a button but doesn’t make it keyboard accessible. You still need JavaScript.
  • Automated testing catches invalid or missing attributes, but you need real screen reader testing to verify the experience makes sense, and support varies across NVDA, JAWS, and VoiceOver.
  • aria-hidden="true" removes content from screen readers entirely, including descendants. It’s easy to accidentally hide important content.
  • Dynamic updates may not announce; changing aria-expanded via JavaScript doesn’t guarantee an announcement unless you use live regions or move focus.

Summary

ARIA gives assistive technologies the semantic information about custom UI that HTML can’t convey. It describes widget types, states, and relationships when you build tabs, menus, or comboboxes, but native HTML elements already have these semantics built in, so ARIA is only for cases where semantic HTML falls short.

See also

Newsletter

A Monthly Email
from Den Odell

Behind-the-scenes thinking on frontend patterns, site updates, and more

No spam. Unsubscribe anytime.