// pattern

Accessible Dropdown Menu Pattern

A dropdown menu triggered by a button, with role="menu", aria-haspopup, arrow key navigation, and focus management. This pattern requires ARIA because there is no native HTML dropdown menu widget.

aria-required intermediate wcag-2.2

// 01 · live demo

Live Demo

Press Enter or Arrow Down to open the menu. Use Arrow Up/Arrow Down to navigate items. Press Escape to close.

Inaccessible version (hover-only, no ARIA):

Settings
Profile
Preferences
Notifications
Help
Sign Out

This dropdown uses a <div> trigger, opens on hover only, has no ARIA roles, no keyboard navigation, and no focus management. Keyboard and screen reader users cannot use it.

// 02 · the code

The Code

HTML
<!-- Dropdown menu container -->
<div class="dropdown">

  <!-- Trigger button: aria-haspopup tells screen readers
       a menu will appear. aria-expanded communicates
       the current open/closed state. -->
  <button type="button"
          aria-expanded="false"
          aria-haspopup="true"
          aria-controls="settings-menu"
          id="menu-trigger">
    Settings
  </button>

  <!-- Menu: role="menu" identifies this as a menu widget.
       li wrappers use role="none" to neutralize list
       semantics that would interfere with the menu role.
       Each interactive item uses role="menuitem". -->
  <ul role="menu"
      id="settings-menu"
      aria-labelledby="menu-trigger"
      hidden>
    <li role="none">
      <button role="menuitem" tabindex="-1">
        Profile
      </button>
    </li>
    <li role="none">
      <button role="menuitem" tabindex="-1">
        Preferences
      </button>
    </li>
    <li role="none">
      <button role="menuitem" tabindex="-1">
        Notifications
      </button>
    </li>
    <li role="none">
      <button role="menuitem" tabindex="-1">
        Help
      </button>
    </li>
    <!-- Separator before destructive action -->
    <li role="none" class="menu-separator">
      <button role="menuitem" tabindex="-1">
        Sign Out
      </button>
    </li>
  </ul>

</div>
CSS
.dropdown {
  position: relative;
  display: inline-block;
}

.dropdown button[aria-haspopup] {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem 1rem;
  border: 2px solid #d1d5db;
  border-radius: 0.375rem;
  background: #fff;
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
  /* SC 2.5.8: minimum 24x24 target */
  min-height: 2.75rem;
}

.dropdown button[aria-haspopup]:hover {
  border-color: #5b2a86;
}

/* SC 2.4.13: focus indicator with 3:1 contrast */
.dropdown button[aria-haspopup]:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: 2px;
}

[role="menu"] {
  position: absolute;
  top: calc(100% + 0.25rem);
  left: 0;
  min-width: 200px;
  margin: 0;
  padding: 0.25rem 0;
  list-style: none;
  background: #fff;
  border: 1px solid #d1d5db;
  border-radius: 0.375rem;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
  z-index: 100;
}

[role="menu"][hidden] {
  display: none;
}

[role="menuitem"] {
  display: block;
  width: 100%;
  padding: 0.5rem 1rem;
  border: none;
  background: none;
  font-size: 0.875rem;
  text-align: left;
  cursor: pointer;
  /* SC 2.5.8: minimum target size */
  min-height: 2.25rem;
}

[role="menuitem"]:hover {
  background: #f3f4f6;
}

/* SC 2.4.13: visible focus on menu items */
[role="menuitem"]:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: -2px;
  border-radius: 0.25rem;
}

/* Visual separator between groups */
.menu-separator {
  border-top: 1px solid #d1d5db;
  margin-top: 0.25rem;
  padding-top: 0.25rem;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .dropdown button[aria-haspopup],
  [role="menuitem"] {
    transition: none;
  }
}
JavaScript
function initDropdownMenu(container) {
  const trigger = container.querySelector(
    '[aria-haspopup="true"]'
  );
  const menu = container.querySelector('[role="menu"]');
  const items = menu.querySelectorAll('[role="menuitem"]');

  function openMenu() {
    menu.hidden = false;
    trigger.setAttribute('aria-expanded', 'true');
    // Focus the first menu item on open
    items[0].focus();
  }

  function closeMenu(returnFocus = true) {
    menu.hidden = true;
    trigger.setAttribute('aria-expanded', 'false');
    if (returnFocus) {
      trigger.focus();
    }
  }

  function focusItem(index) {
    items[index].focus();
  }

  // Toggle menu on trigger click
  trigger.addEventListener('click', () => {
    const isOpen = trigger.getAttribute(
      'aria-expanded'
    ) === 'true';
    if (isOpen) {
      closeMenu();
    } else {
      openMenu();
    }
  });

  // Open menu on Arrow Down from trigger
  trigger.addEventListener('keydown', (e) => {
    if (e.key === 'ArrowDown') {
      e.preventDefault();
      openMenu();
    }
  });

  // Keyboard navigation within the menu
  menu.addEventListener('keydown', (e) => {
    const currentIndex = Array.from(items).indexOf(
      document.activeElement
    );

    switch (e.key) {
      case 'ArrowDown':
        e.preventDefault();
        // Next item, wrap to first
        focusItem((currentIndex + 1) % items.length);
        break;

      case 'ArrowUp':
        e.preventDefault();
        // Previous item, wrap to last
        focusItem(
          (currentIndex - 1 + items.length)
            % items.length
        );
        break;

      case 'Home':
        e.preventDefault();
        focusItem(0);
        break;

      case 'End':
        e.preventDefault();
        focusItem(items.length - 1);
        break;

      case 'Escape':
        closeMenu(); // Returns focus to trigger
        break;

      case 'Tab':
        // Close menu, let focus move naturally
        closeMenu(false);
        break;
    }
  });

  // Activate menu item on click
  items.forEach(item => {
    item.addEventListener('click', () => {
      // Handle the action here
      closeMenu();
    });
  });

  // Close menu when clicking outside
  document.addEventListener('click', (e) => {
    if (!container.contains(e.target)) {
      closeMenu(false);
    }
  });
}

// Initialize all dropdown menus on the page
document.querySelectorAll('.dropdown')
  .forEach(initDropdownMenu);
Why focus the first item on open? When a user opens a menu, they expect to interact with it immediately. Moving focus to the first menu item means they can start navigating with arrow keys right away. If focus stayed on the trigger, the user would have to press Tab to enter the menu — an extra step that breaks the expected menu interaction pattern.

// 03 · why these decisions

Why These Decisions

Why role="menu" and role="menuitem"

These roles communicate the menu widget pattern to assistive technology. Without them, screen readers see a list of buttons — they have no way to know these form a menu, how many items exist, or that arrow keys are expected for navigation. The menu role tells assistive technology to switch to menu interaction mode, where arrow keys navigate and the positional information ("1 of 5") is announced.

Why aria-haspopup="true" on the trigger

This attribute tells screen readers that activating the button will produce a popup menu. Without it, a screen reader user has no way to know that pressing the button will open a menu rather than performing an immediate action. NVDA and JAWS announce "menu" after the button name, and VoiceOver announces "pop-up button" — giving users a clear expectation of what will happen.

Why aria-expanded on the trigger

This attribute communicates whether the menu is currently open or closed. Screen readers announce "collapsed" or "expanded" so users know the current state without needing to see the visual dropdown. Toggling this attribute every time the menu opens or closes keeps the announced state in sync with what sighted users see.

Why <li role="none"> wrappers

The role="menu" pattern expects role="menuitem" children directly inside the menu. When you use a <ul> for the menu container, the <li> elements have implicit listitem semantics that interfere with the menu role hierarchy. Setting role="none" on the <li> elements removes their semantics so assistive technology sees the menu items as direct children of the menu.

Why arrow keys instead of Tab for navigation

The menu pattern uses arrow keys for navigation, just like tabs and menubars. This follows the WAI-ARIA composite widget convention: Tab moves focus into and out of the widget, while arrow keys navigate within it. If menu items were in the tab order, pressing Tab would cycle through every item before moving to the next page element — a poor experience with many items.

Why focus the first item on open

Users expect to interact with a menu immediately after opening it. Moving focus to the first item means arrow keys work right away and screen readers announce the first item's name and position. If focus stayed on the trigger, the user would have to take an additional action to enter the menu, breaking the expected pattern established by desktop operating systems and other ARIA widgets.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Enter / Space Opens the menu (if closed) or activates the focused menu item
Arrow Down Opens the menu (if closed) or moves focus to the next menu item. Wraps from last to first.
Arrow Up Moves focus to the previous menu item. Wraps from first to last.
Home Moves focus to the first menu item
End Moves focus to the last menu item
Escape Closes the menu and returns focus to the trigger button
Tab Closes the menu and moves focus to the next focusable element on the page
Arrow Down on the trigger The Arrow Down key opens the menu when focus is on the trigger button. This is a standard convention from desktop operating systems — users expect to press the down arrow to open a dropdown. It provides an alternative to Enter/Space and signals that the control has a vertical list of options.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 2.1.1 Keyboard Level A — Full keyboard navigation through the menu. The trigger is activatable, arrow keys navigate items, and items are activatable via Enter/Space.
  • 2.1.2 No Keyboard Trap Level AEscape closes the menu and returns focus to the trigger. Tab closes the menu and moves focus to the next page element. Users are never trapped.
  • 4.1.2 Name, Role, Value Level A — The trigger exposes its name (button text), popup hint (aria-haspopup), and expanded state (aria-expanded). Menu items expose their name and menuitem role.
  • 2.4.3 Focus Order Level A — Focus moves logically: trigger to first menu item on open, back to trigger on close. Arrow keys maintain a predictable focus sequence within the menu.
  • 1.3.1 Info and Relationships Level A — The menu structure is conveyed through role="menu" and role="menuitem". The trigger-to-menu relationship is established via aria-controls and aria-labelledby.
  • 2.4.13 Focus Appearance Level AAA — Both the trigger button and menu items have visible focus indicators using a 2px solid outline with 3:1 contrast ratio.
  • 2.5.8 Target Size (Minimum) Level AA — The trigger meets 24x24px minimum via min-height: 2.75rem. Menu items meet the minimum via min-height: 2.25rem and full-width layout.
  • 2.4.11 Focus Not Obscured (Minimum) Level AA — The dropdown menu is positioned below the trigger so it does not obscure the trigger button when open.

// 06 · screen reader behavior

Screen Reader Behavior

When navigating to the trigger

  • NVDA: "Settings, button, menu, collapsed" — announces button name, role, popup hint, and state
  • JAWS: "Settings button, menu" — announces name, role, and popup hint
  • VoiceOver: "Settings, collapsed, pop-up button" — announces name, state, and popup role

When opening the menu

  • NVDA: "Settings, button, menu, expanded" then "Profile, menu item, 1 of 5" — announces the state change, then the first focused item with position
  • JAWS: "Settings button, menu, expanded" then "Profile, 1 of 5" — similar announcement with position
  • VoiceOver: "Settings, expanded, pop-up button" then "Profile, menu item, 1 of 5" — confirms expansion, then announces the first item

When arrowing through items

  • NVDA: "Preferences, menu item, 2 of 5" — announces each item name, role, and position
  • JAWS: "Preferences, 2 of 5" — announces name and position
  • VoiceOver: "Preferences, menu item, 2 of 5" — announces name, role, and position

Position announcements

Screen readers announce "1 of 5", "2 of 5", etc. because the role="menuitem" elements are recognized as children of the role="menu" container. This positional information helps users understand how many items exist and where they are in the set — essential for navigation without visual cues.

// 07 · common mistakes

Common Mistakes

Opening on hover only A hover-only dropdown is completely inaccessible to keyboard users, touch screen users, and many assistive technology users. The menu must be activatable via keyboard (Enter, Space, or Arrow Down). Hover can be added as an enhancement, but keyboard activation must always be the primary interaction.
No role="menu" or role="menuitem" Without these roles, screen readers see only a group of buttons with no semantic relationship. They cannot announce the menu structure, item count, or position ("1 of 5"). Users have no way to know they are inside a menu widget or that arrow keys are available for navigation.
Not managing focus on open If focus stays on the trigger when the menu opens, the user has to Tab into the menu to reach the items. This breaks the expected pattern where opening a menu immediately places focus on the first item. It also means screen readers won't announce the first menu item, leaving users unaware the menu has opened.
Missing aria-expanded Without aria-expanded on the trigger, screen reader users cannot determine whether the menu is open or closed. They must try to navigate into the menu to discover its state. Always toggle aria-expanded between "true" and "false" when the menu opens and closes.
Not closing on Escape If pressing Escape does not close the menu, keyboard users have no reliable way to dismiss it. They must click outside the menu (not possible for keyboard-only users) or tab through all items to exit. Escape must always close the menu and return focus to the trigger.
Using links instead of buttons for action items Menu items that perform actions (like "Sign Out" or "Delete") should use <button role="menuitem">, not <a role="menuitem">. Links imply navigation to a new location. If a menu item navigates the user to a different page (like a "Profile" page), a link is appropriate. But action-only items should always be buttons.

// 08 · native html vs. aria

Native HTML vs. ARIA

There is no native HTML dropdown menu widget. This table shows where native HTML helps and where ARIA is required to build the pattern.

Feature Native HTML contribution ARIA requirement
Trigger element <button> — focusable, keyboard-activatable aria-haspopup="true", aria-expanded
Menu container <ul> — structural container role="menu" overrides list semantics
Menu items <button> inside <li> — activatable role="menuitem", <li role="none">
Open/closed state None aria-expanded="true/false" on trigger
Arrow key navigation None JavaScript keydown handler required
Focus management None JavaScript moves focus to first item on open, back to trigger on close
Menu label None aria-labelledby on menu points to trigger
Hiding when closed hidden attribute — semantic "not relevant" None — hidden handles this natively
The verdict Dropdown menus are another pattern where ARIA is essential. There is no native HTML menu widget (the <menu> element was repurposed and provides no accessibility semantics). You must use ARIA roles, states, and keyboard handling to create the full menu experience. But native HTML still contributes: <button> gives you keyboard activation, and the hidden attribute handles visibility.