Accessible Menu & Menu Button Guide
Most dropdowns on the web should not use role="menu" at all. This guide covers when the WAI-ARIA Menu pattern actually applies, the menu button built from aria-haspopup and aria-expanded, the roving-tabindex focus model, and the disclosure pattern your navigation dropdown should use instead — with copy-ready HTML, JavaScript, and React mapped to WCAG 2.2.
The Most Over-Applied Role in ARIA
Search for “accessible dropdown menu” and most of what you find will tell you to add role="menu" and role="menuitem" to your navigation. That advice is a decade old, it came from CSS framework documentation rather than the specification, and following it makes your navigation measurably worse for screen reader users than plain, unstyled HTML would have been.
The ARIA menu roles model an application menu: the File, Edit, and View menus at the top of a desktop program, where each item runs a command inside the app you are already in. They come with a specific contract — the menu is a single stop in the tab order, arrow keys move between items, and items are commands, not destinations. A site navigation bar breaks every part of that contract. Its items are links to other pages, and “link” is precisely the information a screen reader user needs to hear.
So this guide does two things. It teaches the real menu pattern properly — the menu button, roving tabindex, checkable items, and the keyboard model defined by the WAI-ARIA Authoring Practices Menu and Menubar pattern — because when you need a genuine command menu, you need to get it right. And it shows you the much simpler pattern to use for the case you probably actually have, which is a dropdown full of links.
The one-sentence test: are the items commands or destinations? Commands that act on the current page — Duplicate, Delete, Sort by date — are a menu. Destinations that navigate somewhere else are links, and they belong in a <nav> with a disclosure button if they need to be hidden behind a toggle.
The WCAG 2.2 Criteria a Menu Must Satisfy
| Criterion | Level | What the menu must do |
|---|---|---|
| 1.3.1 Info & Relationships | A | Items are exposed with the role that matches what they do — menu items for commands, links for destinations. |
| 2.1.1 Keyboard | A | Open, move through items, activate, and close — all from the keyboard, never hover-only. |
| 2.1.2 No Keyboard Trap | A | Escape dismisses the menu and Tab moves past it, so focus is never stuck inside. |
| 2.4.3 Focus Order | A | Focus moves into the menu on open and returns to the trigger button on close. |
| 2.4.7 Focus Visible | AA | The focused menu item has a clearly visible indicator, not just a faint hover tint. |
| 2.5.8 Target Size (Minimum) | AA | Menu items are at least 24 by 24 CSS pixels, or spaced so their targets do not overlap. |
| 4.1.2 Name, Role, Value | A | The button exposes its expanded state and popup type; checkable items expose aria-checked. |
1. First, Decide: Menu, Disclosure, or Plain List?
Three different patterns produce a button that reveals a dropdown. Picking the wrong one is the root cause of nearly every broken menu, so make this decision before you write a line of markup.
ARIA menu button
The items are commands acting on the current view — Duplicate, Export, Delete, Sort by name. Single tab stop, arrow-key navigation, role="menu". This is the pattern the rest of this guide builds.
Disclosure button
The dropdown holds links or mixed content — a navigation submenu, a filter panel, an account menu with a heading. Just aria-expanded on a button, and Tab keeps working normally.
No dropdown at all
Fewer than about five destinations that fit on screen? Show them. A visible <nav> with a <ul> of links needs no JavaScript, no ARIA, and no focus management, and it is faster for everyone.
The decision resembles the one between tabs and an accordion: the pattern you choose is a promise about how the keyboard will behave. Choose the one whose promise matches what your component actually is, then keep that promise exactly.
2. Anatomy: The Roles, States, and Properties
A menu button is two elements that reference each other: a real <button> that announces it owns a popup, and a container with role="menu" that holds the items. One rule constrains everything else — a role="menu" may only contain menu items, groups, and separators. Any other content you put inside is not reliably reachable.
| Element | Role | Key attributes |
|---|---|---|
| The trigger button | <button> (no role needed) | aria-haspopup="menu", aria-expanded="true|false", aria-controls="<menu-id>". Has a visible label or an accessible name via aria-label. |
| The popup container | role="menu" | A unique id matching the button's aria-controls, and aria-labelledby pointing at the button so the menu is named. Hidden when collapsed. |
| A command item | role="menuitem" | tabindex="0" on the one active item, tabindex="-1" on the rest (roving tabindex). Contains the visible item label. |
| A toggle item | role="menuitemcheckbox" | aria-checked="true|false", toggled on activation. Use for independent on/off options that do not close the menu. |
| A one-of-many item | role="menuitemradio" | aria-checked="true|false", with only one true per group. Wrap the set in role="group" with an accessible name. |
| A visual divider | role="separator" | Groups items visually and semantically. Not focusable, and skipped by arrow-key navigation. |
Two clarifications that trip people up. aria-haspopup="true" is treated as identical to aria-haspopup="menu", so both are correct, but writing menu explicitly is clearer. And role="menubar" is not a different widget — it is a menu laid out horizontally as a persistently visible bar of menu buttons, and it swaps the arrow-key axis so Left and Right move between top-level items. For how each role and state is exposed to assistive technology, see the ARIA roles & attributes reference.
3. Roving Tabindex: How Focus Moves Inside a Menu
A menu is a composite widget, which means the entire menu occupies one stop in the page tab sequence and the arrow keys move within it. The technique that delivers this is roving tabindex, and the menu is its canonical example.
Roving tabindex (menus, tabs)
Real DOM focus moves. Exactly one item has tabindex={0}; the rest have tabindex={-1}. On Down, you flip the old item to -1, the new item to 0, and call focus() on it.
Virtual focus (combobox)
Focus never leaves the input; aria-activedescendant points at the active option's id. Used when the user must keep typing — which a menu never requires.
Menus use roving tabindex because there is no text field to protect: moving real focus is simpler, and it means document.activeElement always tells you the truth about where the user is. The consequence to remember is that tabindex="-1" makes an element focusable by script but skipped by Tab — that is the whole trick. The focus management guide covers the technique in depth, including focus restoration, which a menu depends on when it closes.
4. The Menu Button: Complete Conformant HTML
Here is a full menu button in its open state, with the first item holding the roving tabindex. Note that the menu is named by the button through aria-labelledby, so a screen reader announces “Actions menu” rather than an anonymous list.
<button
id="actions-button"
aria-haspopup="menu"
aria-expanded="true"
aria-controls="actions-menu"
>
Actions
</button>
<ul
id="actions-menu"
role="menu"
aria-labelledby="actions-button"
>
<li role="menuitem" tabindex="0">Duplicate</li>
<li role="menuitem" tabindex="-1">Export as CSV</li>
<li role="separator"></li>
<li role="menuitem" tabindex="-1">Delete</li>
</ul>When the menu is closed, set aria-expanded="false" on the button and hide the list with the hidden attribute or display: none. Do not hide it with opacity: 0 or by moving it off screen — those leave the items focusable and reachable by a screen reader while invisible on screen.
Why <li role="menuitem"> and not <button>?
Both work, and the APG uses both. The reason to prefer a non-interactive element carrying the role is that role="menuitem" on a <button>replaces the button role anyway, so you gain nothing but keep the browser's default button behaviors to fight. What you must not lose is the keyboard handling: with a plain <li> you own Enter and Space activation yourself. If you would rather inherit that for free, use <button role="menuitem"> and strip its styling. Also note the <ul> loses its list semantics once it carries role="menu", which is expected — a menu is not announced as a list.
5. The Keyboard Model You Must Implement
Choosing role="menu" is a promise that these keys work. Users of screen readers and keyboard-only users recognise the pattern from operating-system menus and will expect every row of this table.
| Key | Expected behavior |
|---|---|
| Enter / Space (on the button) | Opens the menu and moves focus to the first menu item. This is the primary way the menu opens. |
| Down Arrow (on the button) | Opens the menu and moves focus to the first item. Up Arrow opens it and moves focus to the last item. |
| Down / Up Arrow (in the menu) | Moves focus to the next / previous menu item. Wrapping from last to first is optional but recommended. |
| Home / End | Moves focus to the first / last menu item. |
| Escape | Closes the menu and returns focus to the button that opened it. Required — this is the user's way out. |
| Enter / Space (on an item) | Activates the item. A plain menu item runs its action and closes the menu; a checkbox item toggles aria-checked. |
| Tab | Closes the menu and moves focus to the next element in the page tab sequence. The menu is a single tab stop. |
| Printable characters | Optional typeahead: moves focus to the next item whose label starts with that character. Valuable in long menus. |
| Left / Right Arrow | In a horizontal menubar, moves between top-level menus. In a vertical menu with submenus, opens and closes them. |
The two rows people skip are Escape and Tab, and they are the two that keep you clear of 2.1.2 No Keyboard Trap. A user must always have a way out of an open menu without activating anything in it. For the broader keyboard contract every custom widget owes, see the keyboard accessibility guide.
6. The JavaScript: Open, Move, Activate, Close
The whole implementation comes down to four functions. Keep one source of truth for the open state so aria-expanded, the hidden attribute, and focus can never disagree.
const button = document.querySelector("#actions-button")
const menu = document.querySelector("#actions-menu")
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'))
function openMenu(focusLast = false) {
menu.hidden = false
button.setAttribute("aria-expanded", "true")
focusItem(focusLast ? items.length - 1 : 0)
}
function closeMenu(returnFocus = true) {
menu.hidden = true
button.setAttribute("aria-expanded", "false")
// Focus restoration is not optional - without it the user is stranded.
if (returnFocus) button.focus()
}
// Roving tabindex: exactly one item is tabbable at a time.
function focusItem(index) {
items.forEach((item, i) => {
item.tabIndex = i === index ? 0 : -1
})
items[index].focus()
}
button.addEventListener("click", () => {
const isOpen = button.getAttribute("aria-expanded") === "true"
isOpen ? closeMenu() : openMenu()
})
button.addEventListener("keydown", (event) => {
if (event.key === "ArrowDown") { event.preventDefault(); openMenu() }
if (event.key === "ArrowUp") { event.preventDefault(); openMenu(true) }
})
menu.addEventListener("keydown", (event) => {
const current = items.indexOf(document.activeElement)
switch (event.key) {
case "ArrowDown":
event.preventDefault()
focusItem((current + 1) % items.length)
break
case "ArrowUp":
event.preventDefault()
focusItem((current - 1 + items.length) % items.length)
break
case "Home":
event.preventDefault()
focusItem(0)
break
case "End":
event.preventDefault()
focusItem(items.length - 1)
break
case "Escape":
closeMenu()
break
case "Tab":
// Let Tab do its normal thing, but do not restore focus to the button.
closeMenu(false)
break
case "Enter":
case " ":
event.preventDefault()
document.activeElement.click()
closeMenu()
break
}
})
// Clicking outside closes the menu without stealing focus back.
document.addEventListener("pointerdown", (event) => {
if (!menu.contains(event.target) && event.target !== button) {
closeMenu(false)
}
})Three details in that code are load-bearing. preventDefault() on the arrow keys stops the page from scrolling underneath the open menu. Escape restores focus to the button, but Tab deliberately does not, because Tab is already moving focus onward and pulling it back would fight the user. And focusItem updates tabIndex on every item before calling focus(), so the menu always exposes exactly one tab stop no matter where the user left it.
7. Checkable Items: Toggles and One-of-Many Choices
Menus frequently carry state — a sort order, a set of visible columns, a density setting. ARIA has two roles for this, and the distinction is the same as between an HTML checkbox and a radio group.
<ul id="view-menu" role="menu" aria-labelledby="view-button">
<!-- Independent toggles: any combination may be checked. -->
<li role="menuitemcheckbox" aria-checked="true" tabindex="0">
Show archived
</li>
<li role="menuitemcheckbox" aria-checked="false" tabindex="-1">
Show drafts
</li>
<li role="separator"></li>
<!-- One-of-many: the group needs its own accessible name. -->
<li role="group" aria-label="Sort by">
<ul role="none">
<li role="menuitemradio" aria-checked="true" tabindex="-1">Date</li>
<li role="menuitemradio" aria-checked="false" tabindex="-1">Name</li>
<li role="menuitemradio" aria-checked="false" tabindex="-1">Size</li>
</ul>
</li>
</ul>Activating a menuitemcheckbox flips its aria-checked and, by convention, leaves the menu open so the user can set several options at once. Activating a menuitemradio sets it to true, sets its siblings to false, and usually closes the menu since the choice is complete. Never convey the checked state with a tick icon alone — the icon is invisible to a screen reader, and aria-checked is what actually gets announced. The role="none" on the inner list removes its list semantics so it does not interrupt the menu structure.
8. The Right Way to Build a Navigation Dropdown
This is the section most readers actually need. If your dropdown contains links to other pages, do not use any of the markup above. Use a disclosure button and leave the links as links — it is less code, and it is more accessible.
<nav aria-label="Main">
<ul>
<li><a href="/pricing">Pricing</a></li>
<li>
<button aria-expanded="false" aria-controls="resources-submenu">
Resources
</button>
<ul id="resources-submenu" hidden>
<li><a href="/guides">Guides</a></li>
<li><a href="/checklists">Checklists</a></li>
<li><a href="/tools">Tools</a></li>
</ul>
</li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>Count what you get for free. Every link is still announced as a link and still appears in the screen reader's links list. The surrounding <ul> still reports its item count. Tab moves through the revealed links in the obvious way, with no roving tabindex to maintain. There is no focus to move on open and none to restore on close. The entire JavaScript requirement is toggling aria-expanded and the hidden attribute, plus closing on Escape as a courtesy.
Finishing touches for a navigation disclosure
- Name the landmark with
aria-labelif the page has more than one<nav>, so users can tell them apart. - Mark the current page with
aria-current="page"on its link. - Close the open submenu on Escape and return focus to its toggle button.
- Never open on hover alone; if you add hover, keep click and Enter working identically.
- Make sure the toggle is a real
<button>, not a<div>or an<a href="#">.
This is the same disclosure mechanic covered in depth by the accordion and disclosure guide — one button, one aria-expanded, one hidden region. Navigation is simply its most common application.
9. Menus in React
React changes nothing about the roles or the keyboard model, but it does change where the hard parts live. Focus has to be moved imperatively through refs after render, and the menu must be positioned so it never opens off screen. Here is the shape of a hand-rolled version:
function ActionsMenu({ items }) {
const [open, setOpen] = useState(false)
const [activeIndex, setActiveIndex] = useState(0)
const buttonRef = useRef(null)
const itemRefs = useRef([])
const menuId = useId()
// Move real DOM focus whenever the active item changes.
useEffect(() => {
if (open) itemRefs.current[activeIndex]?.focus()
}, [open, activeIndex])
function close({ returnFocus = true } = {}) {
setOpen(false)
if (returnFocus) buttonRef.current?.focus()
}
function onMenuKeyDown(event) {
if (event.key === "ArrowDown") {
event.preventDefault()
setActiveIndex((i) => (i + 1) % items.length)
} else if (event.key === "ArrowUp") {
event.preventDefault()
setActiveIndex((i) => (i - 1 + items.length) % items.length)
} else if (event.key === "Escape") {
close()
} else if (event.key === "Tab") {
close({ returnFocus: false })
}
}
return (
<>
<button
ref={buttonRef}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={menuId}
onClick={() => { setActiveIndex(0); setOpen((o) => !o) }}
>
Actions
</button>
{open && (
<ul id={menuId} role="menu" onKeyDown={onMenuKeyDown}>
{items.map((item, i) => (
<li
key={item.id}
role="menuitem"
ref={(el) => { itemRefs.current[i] = el }}
tabIndex={i === activeIndex ? 0 : -1}
onClick={() => { item.onSelect(); close() }}
>
{item.label}
</li>
))}
</ul>
)}
</>
)
}That covers the mechanics, but it is not what you should ship. Production menus also need collision-aware positioning, portal rendering so the menu escapes overflow: hidden, click-outside handling, typeahead, and submenu timing. Radix DropdownMenu, React Aria's useMenu, and Headless UI's Menu all implement the full pattern and keep pace with ARIA guidance. See the React accessibility guide for how these primitives fit into a component library, and the Vue and Angular guides for their equivalents — Angular's FocusKeyManager in particular exists to implement exactly this roving-tabindex behavior.
10. Submenus, and Why to Think Twice
A submenu is a menu item that opens another menu. The markup is predictable — the parent item takes aria-haspopup="menu" and aria-expanded, exactly like the trigger button, and owns a nested role="menu". Right Arrow opens the submenu and focuses its first item; Left Arrow closes it and returns focus to the parent item; Escape closes the whole chain.
The reason to hesitate is not the ARIA — it is everything around it. Submenus that open on hover need a forgiving delay so the pointer can travel diagonally without the menu snapping shut, which is difficult to tune and hostile to users with motor impairments. On touch screens there is no hover at all, so the interaction has to be redesigned. And nested menus quickly exceed the viewport, colliding with 2.5.8 Target Size and reflow requirements on small screens.
Before adding a level, ask whether a flat menu with role="separator" groups would carry the same information. It usually does, it is far easier to operate with a keyboard or a screen reader, and it removes an entire class of pointer-timing bugs. When you do need submenus, use a library — this is the part of the pattern that is genuinely hard to get right by hand.
How to Test an Accessible Menu
Automated tools catch a missing aria-expanded or an aria-controls that points at nothing. Everything that actually matters about a menu — where focus goes, whether you can escape — needs six minutes of manual testing.
- Put the mouse away. Tab to the button, press Enter, and confirm the menu opens and that focus lands on the first item.
- Arrow through every item. Focus should move visibly, wrap or stop consistently, and never scroll the page behind the menu.
- Press Escape. The menu closes and focus is back on the button — not on
<body>, and not at the top of the page. - Press Tab from inside. The menu closes and focus continues to the next element after the button. Tab should never step through items one by one.
- Count the tab stops. With the menu closed, Tab past it. The whole component should cost exactly one stop.
- Listen with a screen reader. You should hear the button's name, “menu pop-up,” the collapsed or expanded state, and then each item with its position. Use the screen reader testing guide for the commands.
- Check the links list. If the dropdown holds navigation, open the screen reader's links list. Missing entries mean
role="menuitem"has eaten your link semantics.
Layer automated checks on top with axe-core, and see automated vs manual testing for where each fits. Scan the live page with the URL accessibility auditor.
Common Menu Mistakes & How to Fix Them
| Anti-pattern | Why it fails | The fix |
|---|---|---|
| role="menubar" and role="menuitem" on the site navigation. | The roles override link semantics, so items are announced as menu items, vanish from the links list, and no longer signal that they navigate (1.3.1, 4.1.2). | Use <nav> with a <ul> of plain <a> links, and a disclosure button for any dropdown. |
| role="menu" on a container with a search field, a heading, or paragraphs inside. | A menu may only contain menu items, groups, and separators; other content is not reliably reachable or announced (1.3.1). | Use a non-menu popover or disclosure for mixed content, and keep role="menu" for pure item lists. |
| Every menu item is in the tab sequence. | A ten-item menu adds ten tab stops to the page, and the composite-widget keyboard model is lost (2.4.3). | Use roving tabindex: one item at tabindex="0", the rest at tabindex="-1", arrow keys move focus. |
| Escape does not close the menu, or focus is left stranded when it does. | The keyboard user has no reliable way out and can be trapped inside the widget (2.1.2, 2.4.3). | Escape closes the menu and returns focus to the trigger button, every time. |
| The button has no aria-expanded, or it never updates. | Assistive technology cannot tell whether the menu is open, so the user does not know the content appeared (4.1.2). | Toggle aria-expanded on the button itself between true and false on every open and close. |
| The menu opens on hover only. | Keyboard users cannot open it at all, and touch users get an unreliable tap-then-hover interaction (2.1.1). | Make click and Enter/Space open the menu. Treat hover as an enhancement, never the only trigger. |
| Focus stays on the button after the menu opens. | Arrow keys appear to do nothing and a screen reader user is never taken to the items (2.4.3). | Move focus to the first menu item on open, and back to the button on close. |
Accessible Menu Checklist
- Right pattern. The items are commands, not links. If they navigate, it is a disclosure with a
<nav>of links instead. - Real button. A native
<button>witharia-haspopup="menu",aria-expandedthat updates, andaria-controls. - Clean menu contents.
role="menu"holds only menu items, groups, and separators — no headings, forms, or prose. - One tab stop. Roving
tabindex— one item at0, the rest at-1— with arrow keys moving focus. - Focus moves and returns. Opening focuses the first item; Escape closes and restores focus to the button.
- Tab escapes. Tab closes the menu and continues through the page, satisfying 2.1.2.
- State is announced. Checkable items carry
aria-checked, never a tick icon alone. - Visible focus, adequate targets. A clear focus indicator on the active item, and items at least 24 by 24 CSS pixels (2.5.8).
Work through the full WCAG 2.2 checklist to see the menu in the context of every other requirement.
Check Your Menus on a Live Page
Scan any page with our free axe-core-powered auditor to catch a missing aria-expanded, a broken aria-controls target, or a role="menu" full of content that does not belong in one — then run the seven-step keyboard pass above.
Frequently Asked Questions
Should my site navigation use role="menubar" and role="menuitem"?▾
Almost certainly not. The ARIA menu and menubar roles model an application menu — the File, Edit, and View menus of a desktop program — where each item runs a command inside the current application. A site navigation bar is a set of links to other pages, and links are exactly what it should expose. Wrapping your navigation in role="menubar" replaces link semantics with menu semantics, so screen reader users lose the links list, lose the "link" announcement that tells them the item navigates, and gain a keyboard model where Tab no longer moves between items. Use a <nav> element containing a <ul> of <a> elements. Reach for role="menu" only when the items are actions rather than destinations, such as a Copy / Duplicate / Delete row-action menu in an editor.
What is the difference between a menu button and a disclosure?▾
Both are a button that reveals a hidden container, and from the outside they look identical. The difference is what is inside and how the keyboard behaves. A disclosure button carries aria-expanded and reveals arbitrary content — a list of links, a panel of text, a form — and the keyboard just keeps Tabbing through whatever is inside. A menu button carries aria-haspopup="menu" plus aria-expanded and reveals a role="menu" that contains only menu items; opening it moves focus into the menu, arrow keys move between items, the menu is a single Tab stop, and Escape closes it and returns focus to the button. If your dropdown holds navigation links, you want the disclosure. If it holds commands, you want the menu button.
Can menu items be links with href?▾
You can put role="menuitem" on an anchor, and the APG shows navigation menubars built that way, but it comes at a real cost: the role overrides the implicit link role, so assistive technology announces "menu item" instead of "link". The element disappears from the screen reader's list of links, and users lose the cue that activating it will leave the page. Right-click, middle-click, and open-in-new-tab still work in the browser but are no longer advertised. For a dropdown of destinations, the better answer is not to use the menu pattern at all — use a disclosure button revealing a plain <ul> of links, so every link stays a link.
How does focus work inside an ARIA menu?▾
A menu is a composite widget: the whole menu is one stop in the page tab sequence, and the arrow keys move between items inside it. The standard technique is roving tabindex. Exactly one item has tabindex="0" and every other item has tabindex="-1"; when the user presses Down, you set the current item to tabindex="-1", set the next item to tabindex="0", and call focus() on it. Real DOM focus genuinely moves, which is the opposite of a combobox, where focus stays in the input and aria-activedescendant marks the active option. Opening the menu should move focus to the first item, and closing it with Escape must return focus to the button that opened it.
Why does Tab close the menu instead of moving to the next item?▾
Because a menu is designed to be a single tab stop. Inside the menu, the arrow keys are the navigation mechanism, so Tab is free to mean what it means everywhere else on the page: leave this widget. In a menu opened from a menu button, pressing Tab closes the menu and moves focus onward through the normal tab sequence. This is also what keeps you clear of WCAG 2.1.2 No Keyboard Trap — a keyboard user always has two ways out, Escape to dismiss and return to the button, or Tab to move past. If Tab moved item to item, a ten-item menu would put ten stops in the tab order of every page it appears on.
How do I add checkable options, like a sort order or a set of toggles?▾
Use role="menuitemcheckbox" for independent toggles and role="menuitemradio" for a set where exactly one option applies, and put aria-checked="true" or "false" on each. Wrap a radio set in an element with role="group" and give the group an accessible name with aria-label or aria-labelledby, so a screen reader announces the group's purpose before reading the options. Both roles behave like menu items for keyboard purposes — arrow keys move between them and Enter or Space activates — but activating a checkbox item toggles aria-checked rather than closing the menu, while a radio item sets itself to true and clears its siblings.
Should I build a menu from scratch or use a library?▾
Build one by hand to understand the mechanics, then ship a library. The menu pattern has more moving parts than it first appears: roving tabindex, focus return on close, Escape and Tab handling, click-outside dismissal, first-character typeahead, checkable items, positioning that stays on screen, and submenu timing if you have submenus. Radix UI's DropdownMenu, React Aria's useMenu, and Headless UI's Menu all implement the pattern correctly, handle the positioning edge cases, and track changes in ARIA guidance. Note what they give you before you choose: if your dropdown is really navigation, most of these libraries also ship a disclosure or popover primitive that is the better fit.
Essential Accessibility Resources
Comprehensive tools, checklists, and guides to help you create inclusive digital experiences