// 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.
// 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):
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
<!-- 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>
.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;
}
}
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);
// 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 |
// 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 A — Escape 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 andmenuitemrole. - 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"androle="menuitem". The trigger-to-menu relationship is established viaaria-controlsandaria-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 viamin-height: 2.25remand 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
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.
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.
<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 |
<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.