ARIA Roles for Complex UI Patterns: Modals, Tabs, Accordions, and Menus

ARIA Roles for Complex UI Patterns: Modals, Tabs, Accordions, and Menus

Simple semantic HTML is enough for most UI elements — buttons, links, forms, headings. But complex interactive patterns like modals, tabs, accordions, and disclosure menus don't have native HTML equivalents. These require ARIA roles and attributes to communicate structure and state to assistive technology.

The ARIA Authoring Practices Guide (APG) from W3C defines the expected roles, states, properties, and keyboard interactions for each pattern. This guide covers the most common ones with implementation examples and testing guidance.

Before You Use ARIA

The first rule of ARIA: don't use ARIA if a native HTML element works.

<!-- Don't do this -->
<div role="button" tabindex="0" onclick="...">Submit</div>

<!-- Do this -->
<button type="submit">Submit</button>

Native HTML elements have built-in keyboard behavior, focus handling, and implicit ARIA roles. Custom elements with ARIA require you to reimplement everything the browser provides for free. Use ARIA to enhance semantics, not to replace native elements.

Dialogs are the most commonly broken complex component.

Required ARIA

<div role="dialog" 
     aria-modal="true"
     aria-labelledby="dialog-title"
     aria-describedby="dialog-description">
  <h2 id="dialog-title">Delete Account</h2>
  <p id="dialog-description">
    This action cannot be undone. All your data will be permanently deleted.
  </p>
  <button type="button">Cancel</button>
  <button type="button">Delete Account</button>
</div>
  • role="dialog" — tells screen readers this is a modal
  • aria-modal="true" — tells screen readers to treat content outside the dialog as inert
  • aria-labelledby — points to the dialog's heading
  • aria-describedby — optional; provides additional context

Required JavaScript

ARIA attributes alone don't create keyboard behavior. You must implement:

function openDialog(dialogEl, triggerEl) {
    // 1. Show the dialog
    dialogEl.removeAttribute('hidden');
    
    // 2. Move focus inside
    const firstFocusable = dialogEl.querySelector(
        'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    );
    firstFocusable?.focus();
    
    // 3. Trap focus within dialog
    dialogEl.addEventListener('keydown', trapFocus);
    
    // 4. Close on Escape
    document.addEventListener('keydown', function escHandler(e) {
        if (e.key === 'Escape') {
            closeDialog(dialogEl, triggerEl);
            document.removeEventListener('keydown', escHandler);
        }
    });
}

function closeDialog(dialogEl, triggerEl) {
    dialogEl.setAttribute('hidden', '');
    triggerEl.focus(); // Return focus to trigger
}

function trapFocus(e) {
    if (e.key !== 'Tab') return;
    const focusable = [...dialogEl.querySelectorAll(
        'button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
    )].filter(el => !el.disabled);
    const first = focusable[0];
    const last = focusable[focusable.length - 1];
    
    if (e.shiftKey && document.activeElement === first) {
        e.preventDefault();
        last.focus();
    } else if (!e.shiftKey && document.activeElement === last) {
        e.preventDefault();
        first.focus();
    }
}

Testing a Dialog

  1. Open the dialog with keyboard (Tab to trigger, Enter/Space to activate)
  2. Verify: focus moves inside the dialog immediately
  3. Verify: Tab cycles through dialog elements only (doesn't reach background content)
  4. Verify: Shift+Tab works in reverse
  5. Press Escape — verify dialog closes and focus returns to trigger
  6. With screen reader: opening dialog should announce the dialog title

Common failures:

  • Focus stays on trigger when dialog opens
  • Tab reaches background content (no focus trap)
  • Closing dialog doesn't return focus
  • aria-modal="true" missing (screen readers can navigate behind dialog)

Tab Panel

<div class="tabs">
    <div role="tablist" aria-label="Account settings">
        <button role="tab" 
                aria-selected="true" 
                aria-controls="panel-profile"
                id="tab-profile"
                tabindex="0">Profile</button>
        <button role="tab" 
                aria-selected="false" 
                aria-controls="panel-security"
                id="tab-security"
                tabindex="-1">Security</button>
        <button role="tab" 
                aria-selected="false" 
                aria-controls="panel-billing"
                id="tab-billing"
                tabindex="-1">Billing</button>
    </div>
    
    <div role="tabpanel" 
         id="panel-profile" 
         aria-labelledby="tab-profile">
        <!-- Profile content -->
    </div>
    <div role="tabpanel" 
         id="panel-security" 
         aria-labelledby="tab-security"
         hidden>
        <!-- Security content -->
    </div>
</div>

Tab Keyboard Interaction

Tabs use a roving tabindex pattern. Only the active tab is in the tab order (tabindex="0"). Other tabs have tabindex="-1" and are activated with arrow keys:

tablist.addEventListener('keydown', (e) => {
    const tabs = [...tablist.querySelectorAll('[role="tab"]')];
    const index = tabs.indexOf(document.activeElement);
    
    if (e.key === 'ArrowRight') {
        const next = tabs[(index + 1) % tabs.length];
        activateTab(next);
    } else if (e.key === 'ArrowLeft') {
        const prev = tabs[(index - 1 + tabs.length) % tabs.length];
        activateTab(prev);
    } else if (e.key === 'Home') {
        activateTab(tabs[0]);
    } else if (e.key === 'End') {
        activateTab(tabs[tabs.length - 1]);
    }
});

function activateTab(tab) {
    // Update tabindex
    [...tablist.querySelectorAll('[role="tab"]')].forEach(t => {
        t.tabIndex = -1;
        t.setAttribute('aria-selected', 'false');
    });
    tab.tabIndex = 0;
    tab.setAttribute('aria-selected', 'true');
    tab.focus();
    
    // Show correct panel
    const panelId = tab.getAttribute('aria-controls');
    document.querySelectorAll('[role="tabpanel"]').forEach(p => p.hidden = true);
    document.getElementById(panelId).hidden = false;
}

Testing Tabs

  1. Tab to the first tab button — verify it receives focus
  2. Press right arrow — verify next tab activates and gets focus
  3. Press left arrow — verify previous tab activates
  4. Press End — verify last tab activates
  5. Press Home — verify first tab activates
  6. Tab from active tab button — verify focus moves into the tab panel content
  7. With screen reader: activating a tab should announce "Tab Name, selected, tab"

Accordion

<div class="accordion">
    <h3>
        <button type="button"
                aria-expanded="false"
                aria-controls="section-1-content"
                id="section-1-header">
            Shipping Information
        </button>
    </h3>
    <div id="section-1-content"
         role="region"
         aria-labelledby="section-1-header"
         hidden>
        <p>Orders ship within 2-3 business days...</p>
    </div>
    
    <h3>
        <button type="button"
                aria-expanded="false"
                aria-controls="section-2-content"
                id="section-2-header">
            Returns Policy
        </button>
    </h3>
    <div id="section-2-content"
         role="region"
         aria-labelledby="section-2-header"
         hidden>
        <p>Returns accepted within 30 days...</p>
    </div>
</div>

Key attributes:

  • aria-expanded="false/true" on the button — screen readers announce "collapsed" or "expanded"
  • role="region" on the panel — creates a navigable landmark
  • Wrap buttons in heading elements — allows users to navigate accordions by heading level

Testing an Accordion

  1. Tab to accordion button — verify it receives focus
  2. Press Enter or Space — panel expands, button aria-expanded changes to true
  3. With screen reader: "Shipping Information, collapsed, button" → after activation: "Shipping Information, expanded, button"
  4. Tab into expanded content — verify all content is accessible
  5. Activate again — panel collapses, aria-expanded returns to false

Dropdown navigation menus have two patterns: the disclosure pattern (simpler) and the menu pattern (complex, mostly for application menus). Use the disclosure pattern for site navigation.

<nav>
    <button type="button" 
            aria-expanded="false"
            aria-controls="products-menu">
        Products
        <svg aria-hidden="true"><!-- chevron icon --></svg>
    </button>
    <ul id="products-menu" hidden>
        <li><a href="/products/testing">Testing</a></li>
        <li><a href="/products/monitoring">Monitoring</a></li>
        <li><a href="/products/ci">CI Integration</a></li>
    </ul>
</nav>

Keyboard behavior:

  • Enter/Space on button: toggles the dropdown
  • Tab: moves through links when expanded
  • Escape: closes dropdown, returns focus to button

Do NOT implement the complex ARIA menu pattern (with role="menu", role="menuitem", arrow key navigation) for site navigation. It creates a different keyboard interaction model that confuses users who expect Tab to navigate links. The disclosure pattern (simple button + link list) is correct for nav menus.

Testing Navigation Menus

  1. Tab to the "Products" button
  2. Press Enter — verify dropdown opens, aria-expanded="true", items are visible
  3. Tab — verify focus moves to first dropdown link
  4. Tab through links — verify all are reachable
  5. Press Escape — verify dropdown closes, focus returns to "Products" button
  6. With screen reader: button should announce "Products, collapsed, button" / "Products, expanded, button"

Combobox / Autocomplete

<div>
    <label for="city-search">City</label>
    <input type="text"
           id="city-search"
           role="combobox"
           aria-expanded="false"
           aria-autocomplete="list"
           aria-controls="city-listbox"
           aria-activedescendant="">
    <ul id="city-listbox" 
        role="listbox"
        aria-label="Cities"
        hidden>
        <li role="option" id="opt-london">London</li>
        <li role="option" id="opt-paris">Paris</li>
    </ul>
</div>

When a suggestion is highlighted, update aria-activedescendant to point to its ID:

input.setAttribute('aria-activedescendant', 'opt-london');

This tells screen readers to announce the highlighted option without moving focus out of the input.

Testing a Combobox

  1. Tab to input, type a character
  2. Verify list appears, aria-expanded="true"
  3. Press down arrow — first option highlights
  4. With screen reader: should announce the highlighted option name
  5. Press Enter — verify option is selected, field updates, list closes
  6. Press Escape — list closes, field retains what was typed

Testing Checklist

For each complex component in your application:

  • Role is correctly set
  • Focus moves to/within component when activated
  • Tab order is logical
  • Arrow keys work where required (tabs, menus, listboxes)
  • Escape closes component and returns focus
  • State changes (aria-expanded, aria-selected) are updated
  • Screen reader announces name, role, and state
  • Component is tested with both NVDA and VoiceOver

Automated tools like axe-core detect missing ARIA attributes but cannot verify keyboard behavior or dynamic state updates. Manual testing is required for all complex interactive components.

For regression testing between manual audits, HelpMeTest can run automated test scenarios against your live application that verify modals open and close correctly, tabs activate the right panels, and navigation menus respond to keyboard input — catching the most common accessibility regressions before users report them.

Read more

Start now free