Build a dropdown menu
This guide covers any popup menu: dropdown, context, overflow, and the menu bars that open them. The worked example is an application command menu, where items change the interface (create, save, preferences).
Commands, links, and what makes a menu
Two independent choices. Everything else here applies to both.
- Element, per item. A command is a
<button type="button">; a destination is an<a href>that still opens in a new tab, copies as a URL, and works without script. A dropdown may mix them freely: two links and six commands, or one command and six links, changes nothing below. - Interaction model, per dropdown. Arrow keys,
Home/End, andEscmake it a menu: add the focusgroup, let it infermenuitemon every item whether button or link, and make it look like one (a continuous bar or a trigger with a dropdown indicator; see "Menubar or separate menu buttons" below). A short list of links usersTabthrough is navigation: no focusgroup, norole="menu",role="menuitem", oraria-haspopup="menu"; keep the popover,Esc, and focus restoration, and see the navbar guide.
Content never decides the roles. A "Products" dropdown of links can be a menu; an "Account" dropdown of commands cannot be navigation.
Use the menu pattern only for a composite widget with one tab stop and arrow-key navigation. Use a <div focusgroup="menu nomemory">, not the HTML <menu> element. Do not add role="menu" or role="menuitem" in the shared markup: native focusgroup infers those roles, and explicit roles block that inference. Add them only in the unsupported-browser fallback below. nomemory makes reopening start at the first item. Focusgroup moves focus; script handles preview, activation, and focus restoration.
Open and position the menu
Make each trigger a command invoker with commandfor and command="toggle-popover". The browser opens and closes the popover on click, Enter, and Space, and keeps the trigger inside its popover's light-dismiss group, so clicking an open trigger closes it. Do not use popovertarget or popovertargetaction; this pattern uses commandfor instead.
Place each popover immediately after its trigger. Browsers add an implicit aria-details relationship only when the popover is not the next sibling, so this placement avoids it without extra attributes.
<!-- Command invoker: the browser toggles the popover on click, Enter, and Space -->
<button id="file-trigger" class="trigger" type="button" aria-haspopup="menu" aria-expanded="false"
commandfor="file-menu" command="toggle-popover">
File
</button>
<!-- Immediately after its trigger, so browsers add no implicit aria-details -->
<div id="file-menu" popover="auto">
<!-- Nested, not on the popover: Chromium otherwise drops focusgroup keyboard behavior -->
<div focusgroup="menu nomemory" aria-labelledby="file-trigger">
<button type="button">New file</button>
<button type="button">Save</button>
<button id="export-trigger" type="button" aria-haspopup="menu" aria-expanded="false"
commandfor="export-menu" command="toggle-popover">
Export <span aria-hidden="true">›</span>
</button>
<!-- Submenu: popover="auto" directly after its trigger, inside the parent focusgroup -->
<div id="export-menu" popover="auto">
<div focusgroup="menu nomemory" aria-labelledby="export-trigger">
<button type="button">PDF</button>
<button type="button">Plain Text</button>
</div>
</div>
</div>
</div>
Name each menu with aria-labelledby pointing at its trigger. Keep decorative submenu indicators in CSS, or add aria-hidden="true". Give triggers with aria-expanded="true" a visible active style so users can tell which menu is open or previewed.
Menubar or separate menu buttons
Pick one form for the top-level triggers. Role, keyboard model, and styling must match: visually separate buttons signal one Tab stop each.
- Menubar.
role="menubar"on the container (never on a popover), styled as one continuous bar with no gaps between triggers. OneTabstop;ArrowLeft/ArrowRightmove between triggers; hover switches open menus. Use for an application command bar. - Separate menu buttons. Each trigger is its own
Tabstop and looks like a standalone button. Norole="menubar", no arrow keys between triggers, no preview on focus, no hover switching. Wrap in<nav>if they navigate, otherwise no role. Use for toolbars and page headers.
The rest of this guide describes the menubar form. For separate buttons, keep everything inside each menu and drop the menubar-level behavior.
Position the menu with CSS Anchor Positioning
Use CSS anchor positioning, not JavaScript coordinates. Both command="toggle-popover" and showPopover({ source: trigger }) set an implicit anchor, so do not add per-menu anchor-name or position-anchor rules. Menu behavior must not depend on anchor positioning.
/* Top-level menus sit below their trigger. The implicit anchor comes from the invoker or source. */
[popover] {
position-area: block-end span-inline-end;
inset: auto;
}
/* A popover nested in another popover is a submenu: beside its parent item, flipping when it would overflow. */
[popover] [popover] {
position-area: inline-end span-block-end;
position-try-fallbacks: flip-inline, flip-block, --below;
}
/* Last resort for narrow viewports: directly below the trigger, indented, so the submenu never
covers its trigger. */
@position-try --below {
position-area: block-end span-inline-end;
margin-inline-start: 1rem;
/* Fit the area: the submenu's minimum width must not stop this fallback from fitting. */
min-inline-size: 0;
max-inline-size: calc(100% - 1rem);
}
Keep --below last so it applies only when neither side fits. It opens directly under the trigger, so on a narrow screen it covers the sibling items beneath the trigger; that is the accepted trade-off for staying next to the trigger. Also let the submenu shrink to the area (min-inline-size: 0, max-inline-size minus the indent). A fallback that does not fit is skipped, and if none fit the browser keeps the base placement, so a submenu that is a few pixels too wide silently covers its trigger.
For how the flip fallbacks work on a plain popover, see Viewport-Aware Dropdowns. That guide positions a button group, not a menu; for menus, the submenu placement and --below fallback above take precedence.
Animate open and close
Optional. Under 150 ms, opacity and a small translate only, same for submenus, inside @media (prefers-reduced-motion: no-preference). Focus moves on toggle, so the transition never delays keyboard users.
@media (prefers-reduced-motion: no-preference) {
/* Closing: this rule is in effect once :popover-open is removed. */
[popover] {
opacity: 0;
translate: 0 -.25rem;
transition: opacity .15s, translate .15s, display .15s allow-discrete, overlay .15s allow-discrete;
}
/* Opening: faster. */
[popover]:popover-open {
opacity: 1;
translate: 0 0;
transition-duration: .1s;
@starting-style { opacity: 0; translate: 0 -.25rem; }
}
/* Switching between open menubar menus is instant, as in native menubars. */
[role="menubar"]:has(> [popover]:popover-open) > [popover] {
transition: none;
}
}
@starting-style is not a repeat of [popover]: a closed popover is display: none, and transitions do not run from an unrendered state, so it supplies the before-values for the open. Without it the open snaps and only the close animates. display and overlay with allow-discrete keep the popover rendered and in the top layer until the close transition ends.
Keyboard and focus
Do not add a second roving-tabindex system inside a focusgroup. Script covers only what the invokers and focusgroup do not:
- One tab stop for the menubar.
Tabenters at the first top-level trigger.ArrowLeftandArrowRightmove between top-level triggers and wrap at either end.HomeandEndon a trigger enter its menu at the first or last item.- Note: which trigger
Tabreturns to. The APG allows either the first trigger or the last one used. The guide specifies the first, but the demo moves the tab stop withArrowLeftandArrowRight, so after keyboard navigationShift+Tabreturns to the last trigger reached. Pointer use does not move it, so the same sequence can land on a different trigger. Either behavior is acceptable; pick one and keep it consistent.
- Note: which trigger
- Preview. Focusing a top-level trigger shows its menu and sets
aria-expanded="true". Focus stays on the trigger, and no item is focused. Blurring the trigger closes a preview-only menu. - Enter and Space on a preview. They close the preview and keep focus on the trigger. The native
toggle-popoveraction handles this, so the script must not intercept these keys. - ArrowDown on a preview. Converts it to an open menu and focuses the first item.
- Pointer click on an open or previewed trigger. Closes it natively; it does not enter the menu.
- Known difference on submenu triggers. A pointer click on a submenu trigger also toggles natively, so it can close a submenu that hover already previewed, or open one that has not appeared yet. Native menus only open it and never close it. This is harmless, so the guide accepts it.
- Pointer click on a closed trigger. Opens it natively. Focus stays on the trigger; no item is focused. Only keyboard opens enter the menu.
- Pointer over an item. Focuses it without a ring, so hover and focus are one item.
- Pointer over another top-level trigger while a menu is open. Closes it and previews the hovered one, focusing its trigger without a ring. Nothing open: hover does nothing.
- Pointer over a submenu trigger. Previews its submenu after a short delay (pointer focus runs the same preview-on-focus). A sibling item closes it.
- Within a menu.
ArrowUpandArrowDownmove between enabled items.HomeandEndmove to the first and last enabled item, which the script handles. - ArrowLeft or ArrowRight in an open top-level menu. Closes it and previews the adjacent menu with focus on its trigger, wrapping at either end.
ArrowDownthen enters it. Left and Right never land inside a menu. - ArrowRight on a submenu trigger. Opens the submenu and focuses its first item.
- ArrowRight on a submenu item without a child submenu. Closes the hierarchy and previews the next top-level menu while focus stays on its trigger.
ArrowDownthen enters it. - ArrowLeft or Escape in a submenu. Closes only that submenu and restores focus to its trigger. The parent menu stays open.
- Enter or Space on a command. Activates it and closes all open menus.
- Tab. Leaves the menu system. Do not trap focus or step through every command. Closing must not leave focus inside hidden content.
The command event fires before toggle-popover opens the menu, so focus cannot move into the menu there. Move focus in the toggle event instead. HTML restores focus only for the first popover="auto" in a stack, so nested levels must restore focus to their trigger explicitly.
Implementing the interaction
Implement the behaviors above for your own markup. Arrow, Home/End, wrap-around and Tab-to-exit handling is ordinary keydown code. A few parts are easy to get wrong, grouped below by input type.
Keyboard and focus handling
- One open path. Opening by keyboard calls
trigger.click(), so the invoker'stoggle-popoveris the only way a menu opens. Mark the menu so thetogglehandler enters it, keyboard opens only. On a pointer open,togglefocuses the trigger without a ring instead, becausepointerdownprevented focus and arrow keys must still work. - Preview on focus. Focusing a trigger calls
showPopover({ source: trigger })so the menu anchors to it. Ignore focus returning from inside the trigger's own menu, or from another window (flag on windowblur, cleared a tick after windowfocus, or app-switching reopens the last menu). Hide a previewed menu onblurunless focus moved into it. - Move focus in
toggle, notcommand.commandfires before the menu opens. - Restore focus to the trigger yourself. Focus the trigger before
hidePopover(). After a pointer activation, restore focus without a ring (focus({ focusVisible: false })), or the trigger shows the keyboard focus outline after a mouse click; keyboard activations keep the ring. A click withevent.detail === 0came from the keyboard. - Close one level at a time. Call
stopPropagation()onEscapeandArrowLeftin a submenu, or the parent's listener closes it too.
Pointer handling
- Pointer click toggles. Call
preventDefault()onpointerdownso the click doesn't move focus and start a preview mid-click. Remember which trigger was pressed. Clicking a closed or previewed trigger opens or closes it natively and never enters the menu; only keyboard opens enter it. Do not intercept the click. - Hover follows focus. Style
:hoverand:focus-visibleseparately. Onpointermoveover an item, callfocus({ focusVisible: false }); browsers that ignore the option paint no ring for pointer focus anyway. - Hover switches open menus. On
pointerenterof a top-level trigger while another top-level menu is open: hide it, focus the hovered trigger without a ring, and preview on focus (see above) previews it. Nothing open: do nothing. - Safe triangle. A fixed CSS bridge is too narrow: it only works when the pointer starts near the parent's edge. Let CSS draw the bridge and use script only to report the pointer:
- Run this only when
matchMedia('(hover: hover)').matches; it is irrelevant to touch and keyboard. - Start when the pointer leaves a submenu trigger whose submenu is open. Skip a
--belowsubmenu, which has no side edge. - Set
--bridge-xand--bridge-y(the pointer, in px relative to the submenu),--bridge-edge(the submenu's near-edge x:0when the pointer is to its left, its width when it flipped) and--bridge-heighton the submenu, anddata-bridgeto switch the bridge on. - Stop after about 300 ms, when the pointer reaches an item in the submenu, or when the submenu closes. Remove the listener and the attribute. Nothing runs after that.
- CSS gives the submenu a
::beforeclipped to a triangle from the pointer to the near-edge corners. It sits over the sibling items on the pointer's path, so they never receive the hover that would close the submenu.clip-pathalso limits hit testing, so the rest of the parent menu stays interactive. - The submenu needs
overflow: visible(a popover clips by default), and the pseudo-element extends past it, so offset the shape's points by that extent. - Follow-up: a curved path (
shape()curve commands) follows real pointer arcs better than a straight triangle.
- Run this only when
[popover] [popover] {
overflow: visible;
&[data-bridge]::before {
content: "";
position: absolute;
inset: -100vh -100vw; /* Room for the triangle, which can extend far outside the submenu. */
clip-path: shape(
from calc(100vw + var(--bridge-x)) calc(100vh + var(--bridge-y)),
line to calc(100vw + var(--bridge-edge)) 100vh,
line to calc(100vw + var(--bridge-edge)) calc(100vh + var(--bridge-height)),
close
);
}
}
const canHover = matchMedia('(hover: hover)');
// Call on pointerleave of a submenu trigger, with that trigger's submenu.
function startBridge(submenu, event) {
if (!canHover.matches || !submenu.matches(':popover-open')) return;
const rect = submenu.getBoundingClientRect();
if (event.clientX >= rect.left && event.clientX <= rect.right) return; // --below: no side edge.
const style = submenu.style;
style.setProperty('--bridge-edge', `${event.clientX < rect.left ? 0 : rect.width}px`);
style.setProperty('--bridge-height', `${rect.height}px`);
const update = ({ clientX, clientY, target }) => {
if (target !== submenu && submenu.contains(target)) return stop(); // Reached an item.
style.setProperty('--bridge-x', `${clientX - rect.left}px`);
style.setProperty('--bridge-y', `${clientY - rect.top}px`);
};
const timer = setTimeout(() => stop(), 300);
const stop = () => {
clearTimeout(timer);
document.removeEventListener('pointermove', update);
submenu.removeEventListener('toggle', stop);
submenu.removeAttribute('data-bridge');
};
update(event);
submenu.dataset.bridge = '';
document.addEventListener('pointermove', update);
submenu.addEventListener('toggle', stop, { once: true });
}
Hover follows focus (item 2) stays a plain pointermove handler: it needs no triangle logic.
Also:
- Outside focus. Close every open menu when focus lands outside the menu system (a
documentfocusinlistener). - Command activation. Announce it, then hide the root menu and restore focus to its trigger, without a ring if the activation came from a pointer. For checkable items (
menuitemcheckbox,menuitemradio), wait about 300 ms before closing so the changed check mark is visible. Style a closed trigger as active only on:focus-visible; otherwise the restored (ringless) focus leaves it highlighted after a pointer selection. aria-expanded. Set it only in thetogglelistener. It covers preview and open states, and the trigger'saria-expandedstays"true"while its menu is previewed or open.- Helpers. The fallbacks below assume helpers you define for your markup:
triggers(all[commandfor]buttons),menuFor(trigger),isOpen(menu),closeAndRestore(menu, { fromPointer })(focus the trigger, without a ring whenfromPointer, thenmenu.hidePopover()),enterOnOpen(aWeakSetof menus to enter once open) andpointerTrigger(the trigger last pressed by a pointer).
Submenus
- Use
popover="auto"for submenus, placed after the submenu trigger inside the parent'sfocusgroup. Neverpopover="manual". - Nesting lets the browser keep the parent open, close sibling submenus, close child submenus when the root menu closes, and handle outside-click light dismiss.
- The submenu trigger keeps
aria-haspopup="menu"andaria-expanded; thetogglelistener keepsaria-expandedin sync. - The keyboard script handles nested
EscapeandArrowLeftas described above. - A submenu trigger never also runs a command: one control cannot both activate and open, and
Entercannot mean both. Put the default action as the first submenu item, or add a separate command item beside the trigger. If a combined control is forced, document it as an accessibility tradeoff.
Separators and checkable items
- Separate item groups with
<hr>inside the focusgroup: a nativeseparator, not focusable, skipped by focusgroup and the fallback. Never a styled<div>. - A boolean option is a
<button type="button">witharia-checkedand an explicitrole="menuitemcheckbox". Focusgroup does not infer these roles, so this is the one place shared markup sets an item role. - One of a set is a
<button type="button">withrole="menuitemradio"andaria-checked, inside arole="group"with anaria-label, in the same focusgroup. - Script toggles
aria-checked. Enteron a checkbox toggles and closes the menu;Spacetoggles and stays open (APG). Radios close on either key.- Render the check mark from
[aria-checked="true"]::before, not a separate icon element (see the CSS below).
Not covered here
- Activation feedback. Native menus flash the chosen item before closing. If wanted, show a ~100 ms visual confirm before
hidePopover(); do not delay focus restoration or the announcement. - Typeahead. Focusgroup has none. If needed, on printable
keydownmatch the current level's enabled item labels and focus the next match after the current item, wrapping. Do not interceptSpace.
Verify before finishing
An instruction cannot guarantee behavior, so test the result. Open the page and check each of these with the real keyboard and mouse; fix and re-test any that fail:
- Keyboard.
Tablands on the first trigger once;ArrowDownenters;Escapecloses one level and returns focus to its trigger;Tabcloses every menu. - Pointer open. Clicking a trigger opens its menu with focus on the trigger and no ring; clicking again closes it.
- Pointer command. Clicking a command closes every menu and shows no focus ring on the trigger.
- Submenu. Moving diagonally from the middle of a submenu trigger to a submenu item keeps it open; moving to an unrelated sibling closes it.
- Narrow screen. At 375 px no submenu covers its own trigger.
- Check marks. Checked items show a check mark.
- No leftovers. No console errors, and no
popovertarget,popovertargetactionoraria-detailson triggers.
Align menu item content
When a row has an icon, label, and shortcut or submenu indicator, reserve a fixed-size leading slot for the icon or check mark and push the trailing content to the inline end. Icons are a known size, so a fixed slot keeps labels aligned whether or not an item has an icon. Do not use subgrid for rows: any extra inline child (a badge, for example) becomes its own grid item and breaks the layout. Hide shortcut text from assistive technology when it only repeats a key that already operates the item.
[focusgroup~="menu"] {
--menu-icon-size: 1.25rem;
}
/* Target items only. A closed submenu popover inside the focusgroup must keep display: none. */
[focusgroup~="menu"] > button,
[focusgroup~="menu"] > [role="group"] > button {
display: flex;
align-items: center;
gap: .5rem;
}
/* The leading slot holds the icon or check mark. */
[focusgroup~="menu"] button::before {
content: "";
flex: none;
inline-size: var(--menu-icon-size);
}
/* Check mark for checked items. This selector must be at least as specific as the base
::before rule above, or the empty content wins and the check mark never shows. */
[focusgroup~="menu"] button[aria-checked="true"]::before {
content: "✓";
}
/* Shortcut or submenu indicator at the inline end. */
[focusgroup~="menu"] .hint {
margin-inline-start: auto;
}
Put a trailing indicator or shortcut in a <span class="hint" aria-hidden="true"> inside the button; the submenu trigger already announces its popup.
Progressive enhancement and fallbacks
Fallbacks & browser support for focusgroup
Keep focusgroup="menu nomemory" in the markup. If 'focusGroup' in HTMLElement.prototype is false, install Up, Down, Home, and End, set role="menu" on each menu container, and role="menuitem" on buttons with no role; leave checkable items, <hr>, and role="group" alone. Never install it when native focusgroup is present. Left, Right, preview, open, and close are author script either way.
if (!('focusGroup' in HTMLElement.prototype)) {
document.querySelectorAll('[focusgroup~="menu"]').forEach(group => {
group.setAttribute('role', 'menu');
group.querySelectorAll('button:not([role])').forEach(item => {
item.setAttribute('role', 'menuitem');
});
group.addEventListener('keydown', event => {
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
// Own items only: a nested submenu has its own focusgroup, a role="group" wrapper does not.
const items = [...group.querySelectorAll('button')]
.filter(item => item.closest('[focusgroup]') === group)
.filter(item => !item.disabled && item.getAttribute('aria-disabled') !== 'true');
const current = items.indexOf(document.activeElement);
if (current < 0) return;
const next = event.key === 'Home' ? 0 : event.key === 'End' ? items.length - 1 :
(current + (event.key === 'ArrowUp' ? -1 : 1) + items.length) % items.length;
event.preventDefault();
event.stopImmediatePropagation();
items[next].focus({ preventScroll: true });
}, true);
});
}
Fallbacks & browser support for Popover
The Popover API is mostly progressive enhancement, but its defining behaviors — top-layer promotion, light-dismiss, and popovertarget invocation — have no CSS-only equivalent. Older browsers need a polyfill, or a manual fallback if you would rather not ship one.
Polyfill: To support the popover attribute in older browsers, conditionally load @oddbird/popover-polyfill. MANDATORY: Feature detect by checking for the popover property on HTMLElement.prototype, and load the polyfill only when native support is missing — do NOT load it unconditionally.
With a bundler or import map:
// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
import("@oddbird/popover-polyfill");
}
Without a bundler, import from a CDN inside a <script type="module">:
<script type="module">
if (!("popover" in HTMLElement.prototype)) {
import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
}
</script>
Styling caveat: The polyfill cannot define the real :popover-open pseudo-class, so it applies a .\:popover-open class instead. MANDATORY: Combine the two with :is() or :where(), otherwise browsers that lack :popover-open discard the entire rule:
[popover]:is(:popover-open, .\:popover-open) {
display: block;
}
Alternatively, for a legacy fallback without a polyfill, use position: fixed and manually calculate coordinates via getBoundingClientRect() or rely on default positioning with inset: auto if that's acceptable for the use case.
Fallbacks & browser support for Anchor positioning
Without CSS Anchor Positioning, a popover falls back to the UA's centered viewport placement. Never ship that for a menu: it detaches the menu from its trigger, and a centered submenu covers its parent. Gate the anchored rules behind @supports (position-area: block-end) and position the menu from script when it fails:
- Place the menu below its trigger (top level) or beside it (submenu), flipping to the other side and clamping to the viewport when it would overflow.
- Run it in the
togglehandler when the menu opens, and apply the result asleftandtopon the menu. Useposition: fixedwithinset: autoandmargin: 0so the coordinates are viewport-relative. - Keep these overrides inside
@supports not (position-area: block-end), so browsers with anchor positioning are unaffected.
If targeting earlier implementations where showPopover({ source }) does not establish an implicit anchor (no source on ToggleEvent.prototype), give each trigger a unique anchor-name and each menu a matching position-anchor from script, instead of writing per-menu CSS rules.
For browsers without anchor positioning at all, see Viewport-Aware Dropdowns for the polyfill route.
Fallbacks & browser support for Invoker commands
Without invokers, command="toggle-popover" does nothing, so script must toggle the menu.
- Feature-detect the interface, not an instance:
'commandForElement' in HTMLButtonElement.prototype. - On each trigger
click, callpreventDefault()so a browser with invokers cannot toggle twice. - If the menu is open, close it with
closeAndRestore(menu). Otherwise callmenu.showPopover({ source: trigger }). - Mark the menu in
enterOnOpenunless the click came from the pressed trigger (pointerTrigger), so only keyboard opens enter the menu. EnterandSpacestill work because they generate a click on buttons.