ARIA isn’t just about adding attributes; it’s about giving assistive technologies like screen readers the clues they need to interpret your dynamic UI. The most surprising thing about ARIA is that when used correctly, it often makes your UI simpler for sighted users too, by providing consistent interaction models.

Let’s look at a common pattern: a custom dropdown menu.

Imagine you’ve built a dropdown with a button that reveals a list of options.

<button class="dropdown-toggle">
  Select an Option
</button>
<ul class="dropdown-menu">
  <li><a href="#">Option 1</a></li>
  <li><a href="#">Option 2</a></li>
  <li><a href="#">Option 3</a></li>
</ul>

Without ARIA, a screen reader might announce "Select an Option" and then, when the menu opens, announce "Option 1, Option 2, Option 3." The user doesn’t know this is a list of options, or that they can navigate within it.

To fix this, we’ll use ARIA.

First, the button needs to indicate it controls a menu. Add aria-haspopup="true" and aria-expanded="false" (initially).

<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">
  Select an Option
</button>
<ul class="dropdown-menu">
  <li><a href="#">Option 1</a></li>
  <li><a href="#">Option 2</a></li>
  <li><a href="#">Option 3</a></li>
</ul>

When the button is clicked and the menu opens, JavaScript updates aria-expanded to "true". This tells the screen reader, "This button is currently open, revealing something."

// Example: Toggling ARIA attribute on click
const toggleButton = document.querySelector('.dropdown-toggle');
const dropdownMenu = document.querySelector('.dropdown-menu');

toggleButton.addEventListener('click', () => {
  const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true';
  toggleButton.setAttribute('aria-expanded', !isExpanded);
  dropdownMenu.style.display = isExpanded ? 'none' : 'block'; // Or manage visibility class
});

Now, the list itself needs to be identified as a menu. We add role="menu" to the <ul>. Each list item <li> inside the menu should have role="menuitem".

<button class="dropdown-toggle" aria-haspopup="true" aria-expanded="false">
  Select an Option
</button>
<ul class="dropdown-menu" role="menu">
  <li role="menuitem"><a href="#">Option 1</a></li>
  <li role="menuitem"><a href="#">Option 2</a></li>
  <li role="menuitem"><a href="#">Option 3</a></li>
</ul>

This tells the screen reader: "This is a menu. The items within are menu items."

Consider keyboard navigation. Users expect to tab to the button, press Enter or Space to open the menu, use Arrow keys to navigate within the menu items, and press Escape to close it.

If the links inside are the only focusable elements, the user might tab past the button, then into the menu items without ever activating the menu. By making the <a> tags role="menuitem", we’re setting up the expectation for this integrated keyboard experience. The button itself now acts as the primary controller.

When the menu opens, focus should ideally move to the first menu item. When the menu closes, focus should return to the button. This is crucial for a seamless experience.

// Enhanced example with focus management
const toggleButton = document.querySelector('.dropdown-toggle');
const dropdownMenu = document.querySelector('.dropdown-menu');
const menuItems = dropdownMenu.querySelectorAll('li[role="menuitem"]');
let activeMenuItemIndex = -1; // -1 means focus is on the button

toggleButton.addEventListener('click', () => {
  const isExpanded = toggleButton.getAttribute('aria-expanded') === 'true';
  toggleButton.setAttribute('aria-expanded', !isExpanded);
  dropdownMenu.style.display = isExpanded ? 'none' : 'block';
  if (!isExpanded) {
    // Move focus to the first item when opening
    if (menuItems.length > 0) {
      menuItems[0].querySelector('a').focus();
      activeMenuItemIndex = 0;
    }
  } else {
    // Return focus to the button when closing
    toggleButton.focus();
    activeMenuItemIndex = -1;
  }
});

dropdownMenu.addEventListener('keydown', (event) => {
  if (toggleButton.getAttribute('aria-expanded') !== 'true') return;

  const numItems = menuItems.length;

  if (event.key === 'ArrowDown') {
    event.preventDefault();
    activeMenuItemIndex = (activeMenuItemIndex + 1) % numItems;
    menuItems[activeMenuItemIndex].querySelector('a').focus();
  } else if (event.key === 'ArrowUp') {
    event.preventDefault();
    activeMenuItemIndex = (activeMenuItemIndex - 1 + numItems) % numItems;
    menuItems[activeMenuItemIndex].querySelector('a').focus();
  } else if (event.key === 'Escape') {
    event.preventDefault();
    toggleButton.setAttribute('aria-expanded', 'false');
    dropdownMenu.style.display = 'none';
    toggleButton.focus();
    activeMenuItemIndex = -1;
  } else if (event.key === 'Enter' || event.key === ' ') {
    // If focus is on a menu item link, allow activation
    if (activeMenuItemIndex !== -1 && menuItems[activeMenuItemIndex].querySelector('a')) {
       menuItems[activeMenuItemIndex].querySelector('a').click();
    }
  }
});

// Also handle focus return when clicking outside
document.addEventListener('click', (event) => {
  if (!dropdownMenu.contains(event.target) && !toggleButton.contains(event.target)) {
    if (toggleButton.getAttribute('aria-expanded') === 'true') {
      toggleButton.setAttribute('aria-expanded', 'false');
      dropdownMenu.style.display = 'none';
      toggleButton.focus();
      activeMenuItemIndex = -1;
    }
  }
});

The aria-haspopup="true" on the button, combined with aria-expanded which changes dynamically, tells screen readers that this is an interactive control that can reveal more content. The role="menu" and role="menuitem" on the list and its children clearly define the structure and purpose of the revealed content, enabling screen readers to manage focus and announce navigation as expected.

A lesser-known but critical aspect is how the aria-current attribute can be used within menus, particularly for indicating the currently selected or active item if your UI supports that distinction beyond just focus. For example, if one of the menu items represents the currently active filter or selection, aria-current="page" or aria-current="true" can be added to that specific <li> or <a> to convey this state to assistive technology users. This is distinct from keyboard focus and provides semantic meaning about the state of an item within the menu.

The next challenge is handling more complex component states, like disabled buttons or radio button groups.

Want structured learning?

Take the full React course →