cf.completefrontend
My progress
SYSTEM DESIGN · #132

Design a dropdown menu

A design-system action menu: a button opens a short list of commands, and picking one runs it. Drawing the list is easy; the real work is choosing when a menu is the right pattern at all, the full keyboard contract with typeahead and focus return, submenus that survive a diagonal mouse move, positioning that stays on screen, press-drag-release pointer handling, and an API that one shared instance can serve on a 500-row table.

The question

Design the dropdown menu component for our design system. A button, often just a "..." icon, opens a list of actions such as Edit, Duplicate, Move to, Archive and Delete. Some items toggle a setting with a check mark, one opens a submenu, and some are disabled for this user.

Walk me through how it works for mouse, touch, keyboard and screen reader users, how you keep it on screen near the edges of the window, how submenus behave when the mouse moves toward them, and what the API looks like for a team that puts it on every row of a 500-row table.

Real-life examples: The "..." more-actions menu on a GitHub issue or comment, The File, Edit and View menus in the Google Docs menu bar, The row actions menu in Gmail and Google Drive lists, Radix UI DropdownMenu, Headless UI Menu, Ariakit Menu and MUI Menu

Requirements: a list of actions behind a button

A dropdown menu (also called an action menu or a menu button) is a button that opens a floating list of commands. You pick one, the list closes, and the command runs. On GitHub, the "..." button on a comment opens Copy link, Quote reply, Edit, and Delete. In Google Docs, the File menu opens New, Open, Make a copy, and Share.

Nothing is "selected" afterwards: the menu does something, it does not hold a value. That separates it from the select in the dropdown-select guide, which picks a value for a form (a country, a size) and shows it in the trigger. A menu runs an action, and its trigger never changes.

The dropdown-select guide covers listboxes, comboboxes, the general popup positioning rules, and form integration, and it introduces the menu button in a few lines. Here we go deep on the menu itself.

Wireframe of an issue page with a comment card. The three-dot trigger button at the top right of the card is pressed. Below it, aligned to its right edge, an open menu lists Edit, which has focus, Duplicate with the shortcut Ctrl+D, Move to, which has a submenu, a separator, a check item Show labels that is checked, and Delete, which is dimmed because it is disabled. The submenu with Inbox and Projects opens to the left of Move to, because there is no room on the right. Labels name the trigger with aria-haspopup and aria-expanded, the menu with role menu, the check item with aria-checked, and the disabled item with aria-disabled.issues/12ana commented...EditDuplicate Ctrl+DMove to >[x] Show labelsDeleteInboxProjectssubmenuflipped lefttrigger: buttonaria-expanded=truerole=menucheck itemaria-checked=truedisabled itemaria-disabled=true
The trigger is a real button; everything below it is the role="menu" layer. Notice the submenu flipped to the left, and that Delete is dimmed but still in the list, so keyboard and screen reader users can find it.

Questions to ask first

A design-system component has many callers, so the questions are about who will use it and where. Each answer below changes a decision later in the guide.

  • What goes in a menu? Plain actions only, or also check items ("Show line numbers"), radio groups ("Sort by: Name, Date, Size"), submenus, separators, and group labels? Each kind has its own ARIA role and keyboard rule.
  • Is it a single menu button or a menu bar? A row of menus like File, Edit, View that you move across with the arrow keys is a different pattern, called a menubar, with more keyboard rules.
  • Where does it live? In a toolbar, in a scrolling side panel, in every row of a table, inside a modal? A menu inside a scrolling container or a dialog must still escape clipping and stacking.
  • How many items? Five to fifteen is normal. If someone needs 200 items with search, that is a combobox or a command palette, not a menu.
  • Which devices? Mouse, keyboard, touch, and screen readers (NVDA, JAWS, VoiceOver) are all in scope for a design system. Touch has no hover, which matters for submenus.
  • Which frameworks and browsers? We assume React, the last two versions of Chrome, Edge, Firefox and Safari, and right-to-left languages such as Arabic and Hebrew.

Functional requirements

  • A trigger button opens and closes the menu with a click, a tap, Enter, Space, or the arrow keys.
  • Items of four kinds: action (menuitem), check item (menuitemcheckbox), radio item in a group (menuitemradio), and submenu trigger. Plus separators and labelled groups.
  • Full keyboard support: arrows, Home, End, typeahead by letters, Escape, Tab, and focus back on the trigger after close.
  • Submenus open with ArrowRight or hover and close with ArrowLeft or by moving away, with hover intent so a diagonal mouse move does not close them.
  • Disabled items that are visible and announced but cannot run.
  • The menu stays inside the viewport: it flips above the trigger when there is no room below, shifts sideways, and scrolls when it is taller than the space.
  • An optional keyboard shortcut hint on items, like "Ctrl+D" next to Duplicate.
  • A context menu variant that opens at the pointer on right-click or long-press.

Non-functional requirements

RequirementTargetWhy it matters
Open latencyMenu painted within one frame (about 16 ms) of the pressA menu that lags feels broken; people click twice and close it again.
AccessibilityWCAG 2.2 AA; follows the WAI-ARIA APG menu button patternA design system bug is repeated on every screen that uses it.
Scale500 rows with a menu each, with no extra cost per row beyond a buttonMounting 500 hidden menus with 10 items each is 5,000 extra nodes and 500 positioning listeners.
Bundle sizeUnder about 10 KB gzipped for menu plus positioning (an estimate for a budget, not a measured number)It ships on almost every page.
ThemingOnly design tokens such as --cf-* custom properties, no hard-coded colorsDark mode and brands come for free.
InternationalizationRight-to-left layouts mirror submenu direction and arrow keysIn Arabic, a submenu opens to the left and ArrowLeft opens it.

Out of scope, and why

  • Picking a value for a form: that is the select, covered in the dropdown-select guide.
  • Searching hundreds of commands: that is the command palette (its own guide), opened with Cmd+K.
  • Very long lists: the virtualized-combobox guide covers windowing thousands of options.
  • Site navigation links: we will argue in the next chapter that they should not use this component at all.

Check your understanding · Exercise 1

Which answers from the interviewer would change the design of the menu the most? Select all that apply.

Menu, select, or navigation: pick the right pattern first

Three widgets look the same when closed: a button with a little arrow. Open, they all show a floating list. But they make different promises to assistive technology, and picking the wrong one is the most common menu bug in real products. So the first decision is not how to build a menu, but whether this thing should be a menu at all.

Three columns comparing look-alike dropdowns. The action menu, triggered by More actions, lists Edit, Duplicate and Delete; it uses role menu, runs an action, moves with arrows and typeahead, and its trigger never changes. The select, triggered by Size M, lists S, M and L with M picked; it uses role listbox, picks a value, shows the value in its trigger, and submits with a form. The disclosure navigation, triggered by Products, lists the links Pricing, Docs and Blog; it is a nav with links, goes to a page, uses Tab through the links, and has no menu roles. A note says they look the same when closed but make different promises to a screen reader.action menuMore actionsEditDuplicateDeleterole=menuruns an actionarrows, typeaheadtrigger never changesselectSize: MSMLrole=listboxpicks a valuevalue in triggersubmits with a formdisclosure navProductsPricingDocsBlognav with linksgoes to a pageTab through linksno menu rolessame look when closed, different promises to a screen reader
The closed state tells you nothing; the job does. Actions get a menu, values get a select, and pages get a disclosure of plain links.

What role="menu" promises

When a screen reader meets role="menu", it says "menu". On Windows, NVDA and JAWS also switch from browse mode (where letter keys jump between headings and links) to focus mode (where keys go to the page). The user now expects desktop-app behavior: arrow keys move between items, letters jump to items, Enter runs the item, and Escape closes. They do not expect Tab to move through items, and they do not expect links.

The WAI-ARIA Authoring Practices Guide (APG) describes a menu as a widget that offers a list of choices, such as actions or functions, and behaves like the menus of a desktop application. That is the contract you sign by using the role.

Decision A team asks for "a dropdown". Which pattern do we give them?

Action menu (menu button + role="menu")

A button with aria-haspopup="menu" and aria-expanded opens an element with role="menu" holding menuitem, menuitemcheckbox, or menuitemradio children.

Arrow keys, typeahead, Escape and Tab follow the APG menu button pattern.

Pros

  • Right for commands in an app: Edit, Duplicate, Delete, Sort by.
  • Screen readers announce the item kinds and check states.
  • One tab stop for the trigger, arrows inside, like desktop apps.

Cons

  • Wrong for links: screen readers announce the items as menu items, not links, so they drop out of the reader's links list and users lose the cues they rely on for links.
  • More keyboard rules to implement and test than a plain list.

Select (listbox or combobox)

Picks one value (or several) for a form field and shows it in the trigger; the dropdown-select guide covers it.

Pros

  • Holds a value, submits with a form, and announces a selected state.

Cons

  • Wrong for commands: "Delete" is not a value you choose and keep.

Disclosure of links (navigation)

A button with aria-expanded shows and hides a plain list of a links, usually inside nav.

No menu roles. Tab moves through links. Escape closing it is a nice extra.

Pros

  • Links stay links: middle-click, "open in new tab", and the screen reader links list all work.
  • Far less code, and it works in browse mode with no surprises.

Cons

  • No arrow-key movement by default (you can add it, but Tab is the expected key).
  • Not suitable for checkable commands.

What we pick: Our component is the action menu, and its docs say so in the first line: "for commands, not navigation". For a site header with Products and Solutions dropdowns, the design system offers a separate disclosure navigation component. The APG has a Disclosure Navigation Menu example for this case. Adrian Roselli has written for years that ARIA menu roles are overused for site navigation, and Heydon Pickering's Inclusive Components makes the same point: ARIA menus are for application behavior, not navigation. Their advice is a strong default, not a hard law, but the exceptions are rare.

Google Docs has a menubar: File, Edit, View, Insert sit in a row with role="menubar". The whole bar is one tab stop. ArrowLeft and ArrowRight move between the top items, and when a menu is already open, moving sideways opens the next menu straight away, so you can sweep across menus like in a desktop app. A menu button is just one trigger and one menu, like GitHub's "...".

A menubar is a menu button plus a roving focus layer on the bar (the same one-tab-stop, arrows-inside model the tabs guide uses for a tab list) and a shared "is any menu open" flag. Building the single menu right gives you most of the menubar, so our design builds the menu as the core and offers the bar as a thin wrapper. Most product teams only need the button. A menubar makes sense when the page is an application, like a document or code editor, where users expect desktop habits.

Check your understanding · Exercise 2

Which of these should use the action menu component? Select all that apply.

The keyboard contract: open, move, type, close

Once we agree it is a menu, the keyboard behavior is not ours to invent. The APG Menu Button and Menu and Menubar patterns define it, and screen reader users have learned it from desktop apps. Our job is to implement it fully and to make the few choices the APG leaves open.

Roles and states on each element

html
<button id="more-btn" aria-haspopup="menu" aria-expanded="true"
        aria-controls="more-menu">More actions</button>

<div id="more-menu" role="menu" aria-labelledby="more-btn">
  <div role="menuitem" tabindex="-1">Edit</div>
  <div role="menuitem" tabindex="-1" aria-keyshortcuts="Control+D">Duplicate</div>
  <div role="menuitem" tabindex="-1" aria-haspopup="menu"
       aria-expanded="false">Move to</div>
  <div role="separator"></div>
  <div role="group" aria-label="View">
    <div role="menuitemcheckbox" tabindex="-1" aria-checked="true">Show labels</div>
  </div>
  <div role="separator"></div>
  <div role="menuitem" tabindex="-1" aria-disabled="true">Delete</div>
</div>
The rendered markup for an open menu. The trigger keeps its name; the menu is labelled by it. Every item has tabindex="-1" and script moves focus to one of them.
  • The trigger is a real button with aria-haspopup="menu" (or true, which means the same for menus) and aria-expanded that flips between false and true. aria-controls pointing at the menu is optional in the APG.
  • The menu has role="menu" and a name, usually aria-labelledby pointing at the trigger, so a screen reader says "More actions, menu".
  • Items are menuitem, menuitemcheckbox, or menuitemradio. Check and radio items carry aria-checked. Radio items sit in a role="group" with a label, so "Sort by" is announced once for the group.
  • A submenu trigger is a menuitem with its own aria-haspopup and aria-expanded.
  • Separators get role="separator" and are never focusable.

Every key, and what it does

Built from the APG Menu Button and Menu and Menubar patterns. Rows marked optional are choices the APG leaves to us.
KeyWhere focus isWhat happens
Enter or SpaceTriggerOpen the menu and focus the first item.
ArrowDownTriggerOpen and focus the first item (optional in the APG; we do it).
ArrowUpTriggerOpen and focus the last item (optional; we do it, since the last item is often the one you want, like Delete).
ArrowDown / ArrowUpItemNext or previous item, skipping separators and group labels. We wrap from last to first.
Home / EndItemFirst or last item. The APG requires these only when the arrows do not wrap; we support both.
A letter or a few lettersItemTypeahead: move to the next item whose label starts with what was typed (optional in the APG; we do it).
EnterItemRun the item and close the menu (or open the submenu).
SpaceCheck or radio itemToggle it and keep the menu open (APG marks this optional; we do it).
ArrowRight / ArrowLeftItem with submenu / item in submenuOpen the submenu and focus its first item / close it and focus the parent item. Mirrored in right-to-left.
EscapeItemClose the current menu and return focus to its trigger (the button or the parent item).
Tab / Shift+TabItemClose every menu and let focus move on to the next or previous element on the page.
State machine for a menu with four states. Closed, with only the button, moves to open on Enter, Space, the arrow keys or a press. Open, where arrows move focus between items, moves to submenu open on ArrowRight or hover, and submenu open moves back to open on ArrowLeft or Escape. Open moves to closing on Escape or Enter on an item, and submenu open moves to closing on a select in the submenu. Closing returns focus to the trigger and moves to closed. A note says Tab or an outside press closes everything without returning focus.closedbutton onlyopenarrows move focussub openchild focusedclosingfocus to triggerEnter, Space,arrows, pressArrowRight, hoverArrowLeft,EscapeEscape, Enteron an itemselect insubmenufocus back onthe triggerTab, outside press:close all, no return
Every exit has a focus rule. Escape and a select go through closing, which puts focus back on the trigger; Tab and an outside press close everything and let focus go where the user sent it.

Moving focus: roving tabindex or aria-activedescendant

The APG shows both techniques in two versions of its Actions menu button example. The keyboard-navigation and focus-management lessons explain them in general; here is what each means for a menu.

Decision How does the active item get focus?

Roving tabindex (real DOM focus moves)

Every item has tabindex="-1". On each arrow key we call item.focus() on the next one.

The focused item is document.activeElement, so :focus-visible styles it.

Pros

  • Screen readers follow real focus very reliably, including VoiceOver on macOS and iOS.
  • The browser scrolls the focused item into view inside a scrolling menu.

Cons

  • Each move is a real focus change, so focus and blur handlers fire on every arrow press, and every item needs a ref.

aria-activedescendant (focus stays on the menu)

The menu element itself holds DOM focus with tabindex="-1". Its aria-activedescendant names the id of the active item.

We style the active item with a class and scroll it ourselves.

Pros

  • Necessary when focus must stay in a text field, as in a combobox or a command palette.
  • No focus events per move.

Cons

  • For a plain menu there is no text field, so we gain little.
  • Support has historically been less consistent across screen reader and browser pairs, and every item needs a stable id.
  • We must scroll the active item into view by hand.

What we pick: Roving tabindex. A menu has no input that needs to keep focus, and real focus is the most reliable signal for assistive technology. The command-palette and virtualized-combobox guides use aria-activedescendant because their focus must stay in a search box; that is the case where it earns its cost.

Typeahead: a buffer and a timer

The APG lists typeahead as optional: a printable key moves focus to the next item whose label starts with that character. We treat it as required, because long-time desktop users expect it. Libraries also keep a short buffer, so typing "du" quickly jumps to "Duplicate" even when "Delete" comes first. Radix UI clears its buffer 1,000 ms after the last key.

Two details make it feel right. Pressing the same letter again cycles through the items that start with it. And libraries that make disabled items unfocusable, such as Radix, also skip them in the search; our design keeps them focusable, so they stay in it.

ts
let buffer = "";
let timer: number | undefined;

export function typeahead(key: string, labels: string[], current: number): number {
  buffer += key.toLowerCase();
  window.clearTimeout(timer);
  timer = window.setTimeout(() => (buffer = ""), 1000);

  const repeated = [...buffer].every((c) => c === buffer[0]);
  const search = repeated ? buffer[0] : buffer;
  // A single letter starts after the current item, so pressing "d" twice cycles.
  const start = search.length === 1 ? current + 1 : current;
  for (let i = 0; i < labels.length; i++) {
    const index = (start + i) % labels.length;
    if (labels[index].toLowerCase().startsWith(search)) return index;
  }
  return current;
}
Typeahead with a buffer. labels are the visible item texts; repeated letters cycle.

Disabled items: focusable or skipped?

The APG note is short: disabled menu items are focusable but cannot be activated, and they carry aria-disabled="true". The reason is discovery: if Delete is skipped, a screen reader user never learns it exists, or why it is missing. Some libraries choose the opposite, and Radix UI leaves disabled items out of arrow-key and typeahead movement.

Our design follows the APG by default. It uses aria-disabled, never the disabled attribute (which removes the element from focus), and blocks activation in the item handler. A tooltip-like hint ("Only owners can delete") can live in aria-describedby, so the reason is heard too.

Focus the button, then try ArrowUp, Home, the letter d twice, typing "show li" quickly, Space on a check item, ArrowRight on Move to, Escape, and Tab.
The menu's state. The trigger has aria-expanded false. Edit: menuitem. Duplicate: menuitem, aria-keyshortcuts. Move to: menuitem, aria-expanded=false. Show labels: menuitemcheckbox, aria-checked=true. Show line numbers: menuitemcheckbox, aria-checked=false. Delete: menuitem, aria-disabled=true. Submenu closed. Typeahead buffer: empty.More actionsaria-expanded=falseEditmenuitemDuplicatemenuitemaria-keyshortcutsMove tomenuitemaria-expanded=false[x] Show labelsmenuitemcheckboxaria-checked=true[ ] Show line numbersmenuitemcheckboxaria-checked=falseDeletemenuitemaria-disabled=truesubmenu closedtypeahead buffer: "" (clears after 1 s)
The picture mirrors the live menu above: the focused item, each item's role, and the ARIA state that changes as you press keys. The log below says what a screen reader would typically announce.
  1. No keys yet. Tab to More actions and press ArrowUp.

Escape, Tab, and where focus goes after close

Escape and running an item return focus to the trigger. Without that, focus falls to body when the menu unmounts, and a keyboard user starts again from the top of the page. Tab is different: the APG says Tab closes all menus and moves focus out. A menu is not a modal, so we do not trap focus (the modal-dialog guide covers traps).

Letting the browser handle Tab only works when the menu sits right after its trigger in the DOM, as it does with the Popover API. If the menu is portaled to the end of body, Tab would jump to the end of the page, so the Tab handler first moves focus to the trigger and then lets the browser's default move on from there.

One subtle case is an item that opens a dialog, like "Rename...". The menu closes and the dialog opens and takes focus. When the dialog closes, it must return focus to the menu's trigger, because the item it came from no longer exists.

Check your understanding · Exercise 3

A user opens a menu with Edit, Duplicate, Delete (disabled) and presses keys. Which outcomes are correct for our design? Select all that apply.

Sequence for opening the menu with the keyboard, with four columns: User, Trigger, Menu store and Menu content. The user presses ArrowUp on the trigger. The trigger calls open with the last item. The store sets open to true and active to Delete, and the content renders. The content focuses the Delete item, and the user hears that it is unavailable: the screen reader announces Delete, unavailable, menu item, 5 of 5. The user presses Escape. The content tells the store to close with the reason escape, and the store moves focus back to the trigger. A note says the trigger now has aria-expanded false and the reader says More actions, menu button, collapsed.UserTriggerMenu storeContentArrowUpopen(last)active: Deletehears: unavailableEscapeclose(escape)focus triggeraria-expanded=falsereader: collapsedno focus trap: Tabwould leave and close
Focus moves only after the content has rendered, in an effect, never in the key handler. The reader learns the role, name, state and position from the markup; our code only has to move real focus to the right element.

Positioning: anchored, on screen, above everything

The dropdown-select guide covers the base problem (clipping, stacking, the top layer, and the flip, shift and size rules, with a lab), and the tooltip guide adds anchoring. This chapter covers only what is different for a menu: several layers open at once, submenus that sit to the side, and a pointer aiming at items, so a menu that moves is a real bug.

Escaping clipping, with submenus in mind

A menu in a table cell inside a card with overflow: hidden gets cut off, and a menu under a sticky toolbar with a higher z-index opens behind it. Both come from clipping ancestors and stacking contexts (groups of elements the browser paints as one layer). What is new for a menu is that several layers can be open at once: the menu, a submenu, and maybe a submenu of that.

Decision How do a menu and its submenus get out of their container?

A portal per level

Render each menu level with createPortal into a layer element at the end of body, with position: fixed and coordinates from the trigger's getBoundingClientRect().

Pros

  • Works in every browser.
  • One mechanism for every level, fully under our control.

Cons

  • Every open level is a separate subtree, so outside-press detection must know all of them, or a press in a submenu counts as outside its parent.
  • Needs a z-index token for overlays. Inside a modal dialog the portal must target the dialog, or the dialog's inert background swallows it.
  • The menu is far from its trigger in the DOM, so Tab order needs the extra step described in the keyboard chapter.

Nested popovers (Popover API)

Each menu level gets popover="auto" and is shown with showPopover(). The browser moves it to the top layer, which paints above every z-index on the page.

A submenu's element sits inside its parent menu's element, so the browser treats it as a nested popover.

Pros

  • Nested auto popovers stay open together, so opening a submenu does not close its parent. MDN names nested menus as the reason this nesting exists.
  • Escape closes only the topmost popover, so the submenu closes before its parent, as the APG asks.
  • Light dismiss: a press outside the whole stack closes every level.
  • Supported in current Chrome, Edge, Firefox and Safari since 2024, and the DOM stays next to the trigger.

Cons

  • Adds no ARIA. Focus goes back automatically only to declarative invokers, and nested cases can lose it, so we still move focus to the parent item ourselves and sync state from the toggle event.
  • Does not position anything: top-layer elements need coordinates from somewhere.

What we pick: Nested popovers, one popover="auto" element per level, behind one internal Layer component, with the portal kept as a fallback path. The browser's nesting and Escape rules match a menu almost exactly. The one gap is focus, which our store owns anyway.

Placing a submenu: CSS anchor positioning or a library

CSS anchor positioning ties an element to an anchor element in CSS, with no JavaScript and no scroll listeners. It arrived in Chrome and Edge 125 in 2024, Safari 26 in 2025, and Firefox 147 in January 2026, so the last two versions of every major browser now have it. But the newer implementations are young, and real-user analytics usually show a tail of older browsers. A library such as Floating UI computes the same placement in JavaScript with middleware (offset, flip, shift, size) and works everywhere, so it stays as the fallback.

The top-level menu uses the same CSS as a select's popup. The submenu is the menu-specific part: it anchors to its parent item, opens toward the inline end (the right in English), and flips to the other side when there is no room.

css
.menu-item[aria-haspopup] { anchor-name: --move-to; }

@supports (position-anchor: --a) {
  .submenu[popover] {
    position-anchor: --move-to;
    /* beside the item, top edges aligned, growing down */
    position-area: inline-end span-block-end;
    position-try-fallbacks: flip-inline, flip-block;
    max-block-size: calc(100dvh - 16px);
    overflow-y: auto;
  }
}
The submenu's CSS path. Logical keywords make a right-to-left page open it to the left with no extra rule; other browsers get coordinates from Floating UI.

Each trigger and parent item needs a unique anchor name, so the component generates one per instance from React's useId() instead of the fixed name in this sample. When anchor positioning is in every browser our users have (check real-user analytics, not just release notes), we can delete the JavaScript path.

Trigger
No room for the submenu
A window 440 px wide. The trigger sits near the right edge of a left-to-right page, and when the submenu has no room the rule is: flip sides. The menu below the trigger, shifted to stay on screen. The Move to submenu on the left (flipped).windowMoreEditDuplicateMove toArchiveDeleteInboxProjectsTeammenu below the trigger, shifted to stay on screensubmenu on the left (flipped)
The menu aligns to the trigger's start edge (its right edge in right-to-left pages), and the submenu opens toward the end side. Near the edge, compare the rules: doing nothing cuts the submenu off, shifting it covers Move to, and only flipping keeps both visible.
  • Height. A 12-item menu is about 400 px tall, so the size rule from the dropdown-select guide applies on almost every open: take the side with more room, cap the height, and let the menu scroll.
  • Submenus go to the side. The placement is right-start (its top aligned with the parent item), flipping to left-start when there is no room. Right-to-left pages start on the left; logical names (inline-end) make that automatic, as the rtl-layouts lesson explains.
  • Never shift a submenu over its parent. Covering "Move to" hides where the user came from and breaks the safe triangle. Flip to the other side first, and shift only up or down.
  • Do not move while the pointer is aiming. Recomputing placement on scroll is right; recomputing because the menu's own content changed size (a check mark appears) can make it jump under the pointer. We choose the side when a menu opens and keep it until it closes.
  • Scroll and resize while open. Floating UI's autoUpdate listens to scroll on every scrolling ancestor, resize, and a ResizeObserver on both elements. That cost is why we attach it only to an open menu, never to 500 closed ones. When the trigger scrolls out of view, close the menu (or hide it with the hide middleware).

Check your understanding · Exercise 5

The "Move to" submenu would run off the right edge of the window. Which behaviors are right? Select all that apply.

Pointer details: press, drag, release, right-click

A mouse click is three events: pointerdown, pointerup, and then click. Which one opens the menu, which one runs an item, and which one closes the menu from outside are small choices that decide whether the menu feels native or clumsy.

Decision Which event opens the menu from the trigger?

click

Open on click, like any button.

Pros

  • Simplest. Keyboard Enter and Space also fire click on a button, so one handler covers both.
  • No surprise for touch: a scroll gesture that starts on the trigger does not open it.

Cons

  • Opens later than pointerdown by the time the button is held, roughly 100 ms for a normal click (an estimate), which is noticeable next to native menus.
  • Cannot support press-drag-release.

pointerdown for mouse, click for touch and keyboard

On pointerdown with pointerType === "mouse", the left button (button === 0), and no Ctrl key, open the menu and call preventDefault() so the trigger does not grab focus before the menu does.

Touch and pen fall through to click. Keyboard uses keydown.

Pros

  • Feels like macOS and Windows menus: open on press.
  • Enables press-drag-release: press on the trigger, drag to an item, release to run it, in one gesture.

Cons

  • More branches to test.
  • Must ignore Ctrl+click on macOS, which is a right-click.

What we pick: Open on pointerdown for mouse, click otherwise. Radix UI's DropdownMenu trigger also opens on pointerdown, after checking for the left button and no Ctrl key; we add the touch split so a scroll that starts on the trigger does not open it.

Press-drag-release, and the early release trap

With open-on-press, the user's button is still down when the menu appears. They can drag onto "Duplicate" and release: pointerup fires on the item, and we run it. Each item listens for pointerup and runs itself if the press did not start on that item.

But there is a trap. If the menu opens with an item right under the pointer (a context menu opened at the pointer, or a menu that flipped up over the trigger), a quick click's pointerup lands on that item a few milliseconds after the press. The menu opens and runs Delete in one click.

  • Ignore a pointerup on an item that comes within a short time of opening (a few hundred ms is a common choice; treat the exact number as a tuning value) and close to where the press started.
  • Never place the menu over the trigger. Offset it by a few pixels with the offset middleware so the release lands on the trigger, not an item.
  • For context menus, open at the pointer plus 2 px, so the pointer is on the menu's corner, not on an item.
Sequence for press, drag and release with four columns: User, Trigger, Menu and Duplicate item. The user's pointerdown on the trigger opens the menu at once, and the trigger calls preventDefault so it does not take focus. The user drags onto Duplicate and releases: pointerup lands on the item, which checks the guard, selects, and closes the menu. The menu returns focus to the trigger. A red note says a pointerup within the guard time near the press point is ignored, so a quick click cannot run the item that opened under the pointer. A green note says a click with no drag just leaves the menu open.UserTriggerMenuDuplicatepointerdownopen on presspointerup on itemguard ok: selectclose, refocusDuplicate runspointerup too soon, toonear the press: ignoredplain click, no drag:menu stays open
The menu opens on pointerdown, so the same press can end on an item. The guard on pointerup is what stops a fast click from opening and running an item in one go.

Closing on an outside press

A menu closes when you press anywhere else. With nested auto popovers, the browser's light dismiss does this, and we only sync state from the toggle event. The portal fallback listens for pointerdown on the document in the capture phase, so it sees the press before any page handler can stop it, and closes if the target is outside every open level and the trigger.

A press on another menu's trigger should close ours and open theirs in the same gesture, not need two clicks. A press on our own trigger is the one outside-looking press we must ignore, as the next section explains.

Should the outside press also reach the thing under it? macOS menus swallow it: clicking outside only closes the menu. Many web menus let it through, so one click closes the menu and presses the button below, while Radix's default modal mode blocks it. Our default is to let it through for a non-modal menu, because a swallowed click on the web feels like a lost click.

Preventing flashes

  • Toggle on the trigger, not close-then-open. A press on the open trigger must close it once. If outside-dismiss closes it on pointerdown and the trigger's click then opens it again, the menu flashes and stays open.
  • Do not open on hover for top-level menus. Hover-opening a menu button makes menus pop open as the mouse crosses a toolbar. The exception is a menubar: once one menu is open, hovering a sibling top item switches to its menu, like desktop apps.
  • Mount, measure, then show. Paint the menu only after its position is computed, or it appears at the top-left for one frame. With Floating UI, keep the menu hidden until the first computePosition resolves; with CSS anchor positioning, the browser handles it.
  • Short animations, and none with reduced motion. A 100 to 150 ms fade or scale is enough. Under prefers-reduced-motion: reduce, show it instantly.

The context menu variant

A context menu is the same menu opened differently: right-click on a file in Google Drive shows Open, Rename, Share, Remove. The browser fires the contextmenu event on right-click and on the keyboard's context menu key (and Shift+F10 on Windows). We call preventDefault() to suppress the browser's own menu and open ours at clientX and clientY.

Positioning uses a virtual anchor: a zero-size rectangle at the pointer, with flip and shift as usual. A context menu opened from the keyboard has no pointer position, so we anchor it to the focused element instead.

  • Touch has no right-click. Long-press of about 500 ms opens it (a common choice, not a standard), canceled if the finger moves more than about 10 px, which means a scroll. iOS Safari may show its own callout on long-press; -webkit-touch-callout: none on the target suppresses it.
  • Always offer another way. Right-click is invisible. Every action in a context menu must also exist in a visible "..." menu or toolbar, for touch users, for keyboard users who do not know Shift+F10, and for discoverability.
  • Do not hijack right-click everywhere. Override only on the objects that have actions, so people can still copy text or open links in a new tab elsewhere.

Check your understanding · Exercise 6

Users report that a context menu sometimes runs "Delete" when they only meant to open it. What are likely fixes? Select all that apply.

Architecture and testing: a small store and a few behaviors

Everything so far is behavior: keys, focus, hover intent, positioning, dismissal. The naive build puts all of it in one 600-line Menu component with a dozen useEffect calls. It works until someone needs a submenu, a menubar, or a context menu, and then every behavior is copied. The design that scales splits the menu into a small store that owns state, and behaviors that read and write it, with thin components on top.

Architecture of the menu component in three bands. At the top, the caller zone holds RowActions, which renders the menu, and the app's onSelect handlers, which call the API and show a toast. In the middle, the menu component zone holds Menu.Trigger with the button and its ARIA, MenuStore with open state, the active item and the typeahead buffer, and Menu.Content with role menu in the top layer. At the bottom, the behaviors zone holds useHoverIntent, useDismiss and usePosition. RowActions renders the trigger, the trigger calls open on the store, the store drives the content, the content fires onSelect to the handlers, useHoverIntent opens submenus in the store, useDismiss closes the store with a reason, and usePosition places the content.callerRowActionsonSelectmenu componentMenu.Triggerbutton, ARIAMenuStoreopen, active, bufferMenu.Contentrole=menu, top layerbehaviorsuseHoverIntentdelay, triangleuseDismissoutside, Esc, TabusePositionflip, shift, sizerendersonSelect(e)each behavior reads and writes the store
The store is the only place state lives; every behavior reads and writes it. That is why a submenu is just a child store, and why a menubar or a context menu reuses the same parts with a different trigger.
PartResponsibilityWhy it is its own part
MenuStoreOpen state, active item index, item registry, typeahead buffer, parent link for submenusOne source of truth for every behavior; a submenu is just a child store.
Menu.TriggerThe button, its ARIA states, open keys, pointer openThe only part a 500-row table renders per row.
Menu.ContentThe role="menu" element, key handling inside, focus on openMounted only while open.
Menu.Item and kindsRegisters itself, role, aria-checked, aria-disabled, select eventItems come from children or data, in any order, with any markup.
LayerPopover API or portal; top layer or z-index tokenSwappable when browser support changes.
usePositionAnchor positioning or Floating UI with flip, shift, size, autoUpdateThe heaviest code, loaded with the open menu.
useDismissOutside press, Escape, Tab, trigger scrolled awayShared with popovers and the context menu.
useHoverIntentOpen delay, safe triangle, grace timeoutOnly used by submenus and menubars.

The item registry: how the menu knows its items

Arrow keys and typeahead need the items in order, with their labels and disabled flags. Children can be anything: items wrapped in groups, conditional items, items from a map. So each Menu.Item registers itself with the store on mount (its element, its text, disabled) and unregisters on unmount.

The order comes from the DOM, not from registration order. Before moving, we sort registered elements with compareDocumentPosition, or we query [role^=menuitem] inside the content. That stays correct when an item is inserted conditionally in the middle.

How data flows on open and select

Data flow for the menu. A keydown or pointer event goes to the behaviors, which turn it into an intent such as open at index 0 and write it to MenuStore. The store's state goes down to Menu.Content, which renders and moves focus. Enter on an item goes to the Item, which fires onSelect to the app command and sends close with the reason select back to the store, which focuses the trigger. The app command marks the row pending and sends POST duplicate to the API in the server zone. The API's reply, 200 or 500, goes to a toast that says done or offers Retry.eventskey, pointerbehaviorsto intentMenuStoreopen, activeapp cmdrow pendingItemonSelect(e)Contentrender, focuskeydownopen(0)stateEnterselectclose(select)serverPOST /duplicate200 or 500toastdone or Retryfetchreply
The menu's data never leaves the browser: it is events in, state, render and focus out. The only network call is the app's command after onSelect, and the menu has already closed by the time it returns.

Input arrives as a DOM event on the trigger or an item. A behavior turns it into a store change: open = true, activeIndex = 0. The store notifies subscribers, React re-renders the content, and an effect moves real focus to the active item.

When an item is selected, the store emits a select event to the caller's onSelect, which runs the app's command, usually a network call like POST /files/42/duplicate. The menu does not wait for it: it closes and returns focus right away, and the app reports success or failure with a toast. Menus that wait for a server are rare and need a loading item state, which we leave to the caller.

Sequence for selecting Duplicate, with four columns: User, Item, App and API. The user presses Enter on Duplicate. The item fires onSelect to the app, and the menu closes with focus back on the trigger right away. The app sends POST duplicate to the API. The API replies with a 500 error. The app shows a toast saying it could not duplicate, with a Retry button. A note says the menu never waits for the server; another says on success the toast says Duplicated with Undo.UserItemAppAPIEnteronSelectclose, refocusPOST duplicate500 errortoast with Retrythe menu never waitsfor the serveron 200: toastDuplicated, Undo
The menu's job ends at onSelect. The failure path belongs to the app: the row goes back to normal, and the toast explains what happened and offers Retry, in a live region so screen readers hear it.

Check your understanding · Exercise 7

Why does the design keep the item order from the DOM instead of from the order items registered? Select all that apply.

Testing: what screen readers say, and what machines can check

A menu passes a visual review long before it works. The bugs that matter are in what is announced and where focus goes, and they hide from people who test with a mouse. The accessibility-testing and screen-readers lessons cover the tools; this section is the test plan for this component.

What a screen reader user should hear

Exact words differ between NVDA, JAWS, VoiceOver and TalkBack and between versions. Test for the information (role, name, state, position), not the wording.
MomentTypical announcementWhat produces it
Tab to the trigger"More actions, menu button, collapsed" (wording varies)button + accessible name + aria-haspopup + aria-expanded="false"
Press Enter"More actions, menu. Edit, menu item, 1 of 5"role="menu" labelled by the trigger, focus on the first item, position from the tree
ArrowDown to Duplicate"Duplicate, Control+D, menu item, 2 of 5"menuitem with aria-keyshortcuts
ArrowDown to a check item"Show labels, check box menu item, checked" (or similar)menuitemcheckbox + aria-checked="true"
Press Space on it"not checked"aria-checked changed while focus stayed put
Arrow to Delete"Delete, unavailable, menu item" or "dimmed" on VoiceOveraria-disabled="true" on a focusable item
Press Escape"More actions, menu button, collapsed"Focus back on the trigger, aria-expanded="false"

The manual matrix covers the pairs most used in practice: NVDA with Chrome or Firefox and JAWS with Chrome on Windows, VoiceOver with Safari on macOS and iOS, and TalkBack with Chrome on Android. Two things only humans catch: whether NVDA and JAWS switch to focus mode when the menu opens (if not, arrow keys read the page instead of moving in the menu), and whether VoiceOver on iOS can reach and activate items with swipes, since there are no arrow keys.

Automated tests, by layer

  • Unit tests for the pure parts: typeahead matching (repeated letters cycle, the buffer clears after 1,000 ms with fake timers), the triangle test, and the next-index function with disabled items and wrapping.
  • Component tests with Testing Library: query by role, never by class: getByRole("button", { name: "More actions" }), press {ArrowUp}, expect getByRole("menuitem", { name: "Delete" }) to have focus and aria-disabled. Assert focus returns to the trigger after Escape and after select. jsdom has no layout, so do not test positions here.
  • axe-core in those tests: it catches a menu without items, an item outside a menu, a missing name on an icon-only trigger, and invalid aria-checked on a plain menuitem.
  • Browser tests with Playwright: real layout and real pointer events. Place the trigger 20 px from each window edge and assert the menu stays inside the viewport. Drag from trigger to an item and release. Move the mouse diagonally to a submenu and assert it stays open. Run in Chromium, Firefox and WebKit.
  • Visual regression: screenshots of the open menu in light, dark, right-to-left and forced-colors mode (Windows High Contrast), where focus must be visible without our background colors.
ts
test("ArrowUp opens on the last item and Escape returns focus", async () => {
  const user = userEvent.setup();
  const onDelete = vi.fn();
  render(<RowMenu canDelete={false} onDelete={onDelete} />);
  const trigger = screen.getByRole("button", { name: "More actions" });

  trigger.focus();
  await user.keyboard("{ArrowUp}");
  const del = screen.getByRole("menuitem", { name: "Delete" });
  expect(del).toHaveFocus();
  expect(del).toHaveAttribute("aria-disabled", "true");
  expect(trigger).toHaveAttribute("aria-expanded", "true");

  await user.keyboard("{Enter}");
  expect(onDelete).not.toHaveBeenCalled();

  await user.keyboard("{Escape}");
  expect(screen.queryByRole("menu")).toBeNull();
  expect(trigger).toHaveFocus();
});
A component test that checks behavior through roles, the way a user meets the menu.

Check your understanding · Exercise 8

Which bugs would a jsdom component test with Testing Library NOT catch? Select all that apply.

The component API, its data model, and 500 rows

The API is the part every product team touches, so it carries most of the design system's long-term cost. The component-api-design lesson covers the general trade-offs; here are the menu-specific ones.

Decision Children or data?

Compound components

Menu.Root, Menu.Trigger, Menu.Content, Menu.Item, Menu.CheckboxItem, Menu.RadioGroup, Menu.Sub, Menu.Separator, sharing the store through context.

Radix, Headless UI and Ariakit all use this shape.

Pros

  • Any markup inside an item: icons, shortcut hints, a badge.
  • Conditional items are plain JSX.
  • Each part takes its own className and refs.

Cons

  • More imports and more nesting for a simple menu.
  • Callers can build wrong trees, such as an item outside content; we warn in development.

Items from a data array

items={[{ id: "edit", label: "Edit", onSelect }, ...]} on one Menu component.

Pros

  • Short for simple menus. Easy to generate from permissions or server config.
  • Easy to share one menu definition across rows.

Cons

  • Every new need (an icon, a badge, a description) becomes a new field.
  • Custom rendering ends in renderItem props, which is compound components with extra steps.

What we pick: Compound components as the core, plus a thin ActionMenu wrapper that takes a data array for the 80 percent case. The wrapper renders the compound parts, so there is one implementation and one set of tests.

tsx
<Menu.Root>
  <Menu.Trigger aria-label="More actions"><DotsIcon /></Menu.Trigger>
  <Menu.Content>
    <Menu.Item onSelect={edit}>Edit</Menu.Item>
    <Menu.Item onSelect={duplicate} shortcut="Control+D">Duplicate</Menu.Item>
    <Menu.Sub label="Move to">
      {folders.map((f) => (
        <Menu.Item key={f.id} onSelect={() => move(f.id)}>{f.name}</Menu.Item>
      ))}
    </Menu.Sub>
    <Menu.Separator />
    <Menu.CheckboxItem checked={labels} onCheckedChange={setLabels}
      onSelect={(e) => e.preventDefault()}>Show labels</Menu.CheckboxItem>
    <Menu.Separator />
    <Menu.Item onSelect={remove} disabled={!canDelete}
      description="Only owners can delete">Delete</Menu.Item>
  </Menu.Content>
</Menu.Root>
What a caller writes. onSelect with preventDefault() keeps the menu open.

Controlled or uncontrolled open state

Most callers never think about open state, so the menu is uncontrolled by default: it keeps open inside. Some need control, such as a product tour that opens the menu to point at an item, or a test. So Menu.Root also takes open and onOpenChange(open, reason), the same pattern React uses for value and onChange on inputs.

The reason ("trigger", "escape", "outside", "select", "tab") matters. A caller who wants to keep the menu open after a select, but still close on Escape, can tell the two apart.

onSelect as a cancelable event

After an item runs, the menu closes. Check items are the common exception: flipping "Show labels" and "Show grid" one after the other should not need two openings. Instead of a closeOnSelect prop on every item, onSelect receives an event, and calling event.preventDefault() keeps the menu open. Radix does this by dispatching a cancelable custom event on the item, so a wrapper can decide per call while the default stays the common case.

Keyboard shortcut hints

Showing "Ctrl+D" next to Duplicate teaches the shortcut. The shortcut prop renders the hint, localized per platform ("Cmd+D" on macOS), and sets aria-keyshortcuts with the ARIA spec's key names (Control+D, or Meta+D on macOS). The attribute only announces the shortcut; it does not make it work, so the app registers the real key handler at page level, where it works while the menu is closed.

Screen reader support for aria-keyshortcuts varies. Pick one channel so the shortcut is not read twice: either hide the visible hint with aria-hidden and rely on the attribute, or leave the hint readable and skip the attribute. We choose the attribute and test it in the screen reader matrix.

The data model

ts
type ItemKind = "action" | "checkbox" | "radio" | "submenu";

type RegisteredItem = {
  id: string;            // from useId(), used for aria-controls and tests
  kind: ItemKind;
  element: HTMLElement;  // for focus() and DOM order
  textValue: string;     // typeahead label, defaults to textContent
  disabled: boolean;
};

type MenuState = {
  open: boolean;
  activeId: string | null;
  items: Map<string, RegisteredItem>;
  typeahead: { buffer: string; clearAt: number };
  parent: MenuStore | null;     // set for submenus
  openSubId: string | null;     // at most one open child
  placement: "bottom-start" | "top-start" | "right-start" | "left-start";
};
Client state only: a menu has no server data of its own. All of it lives in the store in component memory.

Note what is not in the store: the checked values. checked belongs to the caller (it is app state, like "show labels"), so check and radio items are controlled by the caller. The menu reflects it in aria-checked and reports changes. That keeps one source of truth when the same setting also appears in a toolbar.

Check your understanding · Exercise 9

Which API choices help a caller who builds a "View" menu with three check items that users often toggle together? Select all that apply.

Performance at scale

One menu is cheap. The cost appears when a data table renders a "..." menu on each of 500 rows. The data-table guide covers the table; this section is about what each row's menu costs.

What the naive version costs

Suppose each row renders a full menu, hidden with CSS, with 10 items, each an icon, a label and a shortcut hint. That is about 4 DOM nodes per item, so roughly 45 nodes per menu with the wrapper and separators, and about 22,500 extra nodes across 500 rows. Add 500 sets of event listeners and, if positioning runs while hidden, 500 scroll listeners.

Lighthouse's DOM size audit has long warned at about 800 nodes on the whole page and failed at about 1,400, so the hidden menus alone would be many times over. Style recalculation, layout and memory all grow with node count. These are estimates for this example, but the order of magnitude is the point.

Decision How do 500 rows each get a menu without 500 menus?

Lazy mount per row

Each row renders only Menu.Trigger. Menu.Content mounts when that row's menu opens and unmounts on close.

Pros

  • Zero menu nodes until opened; one menu at a time in the DOM.
  • No API change for callers: it is the default.

Cons

  • Still 500 store instances and context providers, small but not free.
  • Each row closes over its own item list and handlers, which re-render with the row.

One shared menu instance

The table renders one Menu.Root with one Menu.Content. Each row renders a light trigger that, when pressed, tells the shared menu "open for row 42, anchored to this button".

The content reads the active row's data to decide items: Delete disabled if the user does not own row 42.

Pros

  • One store, one positioning setup, one set of listeners for the whole table.
  • Rows stay cheap to re-render, which matters in virtualized tables where rows mount and unmount on scroll.

Cons

  • The trigger and menu are no longer parent and child, so aria-controls, aria-expanded per trigger, and focus return must point at the right row's button.
  • If the active row scrolls out of a virtualized list and unmounts, the menu loses its anchor and must close.

What we pick: Lazy mounting is the default for every menu, because it is free for callers. For tables, the design system also exports a SharedMenu with a useSharedMenuTrigger(rowData) hook. That is how large data grids usually do row actions, and it keeps the cost of a row at one button.

Before and after for a table of rows with a three-dot button each. On the left, the naive version: every row has its own hidden menu next to its button, drawn dashed, which adds up to 500 hidden menus and about 22,500 extra nodes. On the right, the shared version: every row has only its button, and one menu with Edit, Move and Delete is anchored to the button of row 3, which is pressed. Labels say one store, one menu, one set of listeners.naive: a menu per rowshared: one menurow 1...row 1...row 2...row 2...row 3...row 3...row 4...row 4...row 5...row 5...EditMoveDelete500 hidden menusabout 22,500 extra nodes1 store, 1 menulisteners only while open
A row costs one button. The shared menu takes its anchor and its row data from whichever trigger was pressed, and it must remember that trigger to return focus to it.
  • Code-split the heavy parts. The trigger is tiny; positioning and the hover-intent code load with the content on first open. A pointerenter or focus on any trigger can start that import early, so the first open still lands in one frame.
  • Keep the open path synchronous. No await between the press and the first paint of the menu. If items depend on permissions, fetch them with the page, not on open.
  • Measure with the Performance panel. Press to first paint counts toward Interaction to Next Paint (INP), where good is 200 ms or less; a menu should take about one frame.

Check your understanding · Exercise 10

A virtualized table of 10,000 rows uses one shared row menu. Which behaviors must the design handle? Select all that apply.

Running it in the interview

In 45 minutes you cannot say everything in this guide, and you should not try. The interviewer is checking that you see past "a div that toggles" to the parts that make a menu correct for everyone, and that you can make trade-offs out loud.

MinutesWhat to doWhat to draw or say
0 to 5ScopeActions, not values or links. Item kinds. Menubar or single button. Where it lives (tables, dialogs). Devices and screen readers.
5 to 10Pattern and markupThe menu-versus-select-versus-disclosure decision in one sentence. The trigger and menu markup with roles and states.
10 to 20Keyboard and focusThe key table from memory, roving tabindex and why, typeahead buffer, disabled items focusable, focus return.
20 to 28Mouse and touchOpen on press with drag-release, the early release guard, outside dismiss, submenu hover intent with the triangle.
28 to 35Positioning and layeringTop layer or portal, flip, shift, max height with scroll, submenus to the side, right-to-left.
35 to 42API and scaleCompound components, onSelect with preventDefault, controlled open with a reason, the shared instance for tables.
42 to 45TestingThe screen reader matrix and which bugs need a real browser.

Say your trade-offs out loud: "roving tabindex, because there is no text field to keep focus", "disabled items stay focusable, as the APG says, though Radix skips them". A named trade-off with a reason scores higher than a perfect answer given silently.

Think first

The interviewer asks: "Why not use a native element, like select or the old menu element?"

Think first

"Should the rest of the page be inert while the menu is open, like a modal?"

Think first

"Delete takes two seconds on the server. What does the menu do?"

Think first

"How does it look on a phone?"

Check your understanding · Exercise 11

You have 5 minutes left and have not discussed positioning. What is the best use of them? Select all that apply.

Summary

  • A menu runs actions in app-like UI. Values belong in a select, and site navigation links belong in a disclosure of links, not role="menu".
  • The APG menu button pattern is the contract: aria-haspopup and aria-expanded on a real button, menu with menuitem, menuitemcheckbox and menuitemradio, arrows, Home, End, typeahead, Escape back to the trigger, and Tab out.
  • Roving tabindex fits a menu because there is no text field that must keep focus; disabled items stay focusable with aria-disabled so they are discoverable.
  • Submenus need hover intent for the mouse: a short open delay, a safe triangle toward the submenu, and a grace timeout. Touch opens on tap.
  • Escape clipping with nested auto popovers in the top layer (or a portal), and stay on screen with flip, shift and a capped height; submenus flip sides but never cover their parent. CSS anchor positioning is in every current browser but young, so keep a JavaScript fallback until analytics say otherwise.
  • Open on pointerdown for mouse to allow press-drag-release, and guard against the opening press releasing on an item.
  • Compound components with a cancelable onSelect, controlled open state with a reason, and caller-owned checked values make an API that lasts.
  • Mount content lazily, and share one menu instance across a large table so a row costs one button.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 3
Mid-level 0 of 4

References

Concepts behind it

Try next