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.
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
| Requirement | Target | Why it matters |
|---|---|---|
| Open latency | Menu painted within one frame (about 16 ms) of the press | A menu that lags feels broken; people click twice and close it again. |
| Accessibility | WCAG 2.2 AA; follows the WAI-ARIA APG menu button pattern | A design system bug is repeated on every screen that uses it. |
| Scale | 500 rows with a menu each, with no extra cost per row beyond a button | Mounting 500 hidden menus with 10 items each is 5,000 extra nodes and 500 positioning listeners. |
| Bundle size | Under about 10 KB gzipped for menu plus positioning (an estimate for a budget, not a measured number) | It ships on almost every page. |
| Theming | Only design tokens such as --cf-* custom properties, no hard-coded colors | Dark mode and brands come for free. |
| Internationalization | Right-to-left layouts mirror submenu direction and arrow keys | In 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.
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?
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.
A single menu button, or a menubar?
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
<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>
tabindex="-1" and script moves focus to one of them.- The trigger is a real
buttonwitharia-haspopup="menu"(ortrue, which means the same for menus) andaria-expandedthat flips betweenfalseandtrue.aria-controlspointing at the menu is optional in the APG. - The menu has
role="menu"and a name, usuallyaria-labelledbypointing at the trigger, so a screen reader says "More actions, menu". - Items are
menuitem,menuitemcheckbox, ormenuitemradio. Check and radio items carryaria-checked. Radio items sit in arole="group"with a label, so "Sort by" is announced once for the group. - A submenu trigger is a
menuitemwith its ownaria-haspopupandaria-expanded. - Separators get
role="separator"and are never focusable.
Every key, and what it does
| Key | Where focus is | What happens |
|---|---|---|
| Enter or Space | Trigger | Open the menu and focus the first item. |
| ArrowDown | Trigger | Open and focus the first item (optional in the APG; we do it). |
| ArrowUp | Trigger | Open and focus the last item (optional; we do it, since the last item is often the one you want, like Delete). |
| ArrowDown / ArrowUp | Item | Next or previous item, skipping separators and group labels. We wrap from last to first. |
| Home / End | Item | First or last item. The APG requires these only when the arrows do not wrap; we support both. |
| A letter or a few letters | Item | Typeahead: move to the next item whose label starts with what was typed (optional in the APG; we do it). |
| Enter | Item | Run the item and close the menu (or open the submenu). |
| Space | Check or radio item | Toggle it and keep the menu open (APG marks this optional; we do it). |
| ArrowRight / ArrowLeft | Item with submenu / item in submenu | Open the submenu and focus its first item / close it and focus the parent item. Mirrored in right-to-left. |
| Escape | Item | Close the current menu and return focus to its trigger (the button or the parent item). |
| Tab / Shift+Tab | Item | Close every menu and let focus move on to the next or previous element on the page. |
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?
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.
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;
}
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.
- 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.
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?
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.
.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;
}
}
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.
What is different for a menu
- 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 toleft-startwhen 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
autoUpdatelistens to scroll on every scrolling ancestor,resize, and aResizeObserveron 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 thehidemiddleware).
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?
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
pointerupon 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
offsetmiddleware 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.
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
pointerdownand the trigger'sclickthen 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
computePositionresolves; 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: noneon 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.
| Part | Responsibility | Why it is its own part |
|---|---|---|
MenuStore | Open state, active item index, item registry, typeahead buffer, parent link for submenus | One source of truth for every behavior; a submenu is just a child store. |
Menu.Trigger | The button, its ARIA states, open keys, pointer open | The only part a 500-row table renders per row. |
Menu.Content | The role="menu" element, key handling inside, focus on open | Mounted only while open. |
Menu.Item and kinds | Registers itself, role, aria-checked, aria-disabled, select event | Items come from children or data, in any order, with any markup. |
Layer | Popover API or portal; top layer or z-index token | Swappable when browser support changes. |
usePosition | Anchor positioning or Floating UI with flip, shift, size, autoUpdate | The heaviest code, loaded with the open menu. |
useDismiss | Outside press, Escape, Tab, trigger scrolled away | Shared with popovers and the context menu. |
useHoverIntent | Open delay, safe triangle, grace timeout | Only 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
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.
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
| Moment | Typical announcement | What 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 VoiceOver | aria-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}, expectgetByRole("menuitem", { name: "Delete" })to have focus andaria-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
menuwithout items, an item outside a menu, a missing name on an icon-only trigger, and invalidaria-checkedon a plainmenuitem. - 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.
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();
});
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?
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.
<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>
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
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";
};
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?
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.
- Code-split the heavy parts. The trigger is tiny; positioning and the hover-intent code load with the content on first open. A
pointerenterorfocuson any trigger can start that import early, so the first open still lands in one frame. - Keep the open path synchronous. No
awaitbetween 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.
| Minutes | What to do | What to draw or say |
|---|---|---|
| 0 to 5 | Scope | Actions, not values or links. Item kinds. Menubar or single button. Where it lives (tables, dialogs). Devices and screen readers. |
| 5 to 10 | Pattern and markup | The menu-versus-select-versus-disclosure decision in one sentence. The trigger and menu markup with roles and states. |
| 10 to 20 | Keyboard and focus | The key table from memory, roving tabindex and why, typeahead buffer, disabled items focusable, focus return. |
| 20 to 28 | Mouse and touch | Open on press with drag-release, the early release guard, outside dismiss, submenu hover intent with the triangle. |
| 28 to 35 | Positioning and layering | Top layer or portal, flip, shift, max height with scroll, submenus to the side, right-to-left. |
| 35 to 42 | API and scale | Compound components, onSelect with preventDefault, controlled open with a reason, the shared instance for tables. |
| 42 to 45 | Testing | The 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?"
The select element picks a value for a form; it cannot run commands, show check items for settings, or hold submenus, and styling its popup is limited (the customizable select that shipped in Chromium in 2025 is still a select). The HTML menu element today is just a list, semantically like ul; the old context menu feature built on it was removed from browsers.
So the platform gives us parts, not the whole: button, the Popover API for the top layer and light dismiss, and CSS anchor positioning. The ARIA roles and the keyboard model are ours to build.
Think first
"Should the rest of the page be inert while the menu is open, like a modal?"
Not by default. A menu is non-modal: Tab leaves it, and outside clicks close it and usually go through. Making the page inert would trap screen reader users in a small list for no gain.
Some libraries do make dropdown menus modal by default (Radix has a modal prop that defaults to true, which blocks outside pointer events and hides the rest from screen readers). It prevents accidental clicks through, at the cost of a swallowed click. Name the trade-off and pick one consistently for the design system.
Think first
"Delete takes two seconds on the server. What does the menu do?"
The menu closes at once and focus returns to the trigger; the menu is not the right place for progress. The row shows a pending state, and a toast reports success or failure with Undo or Retry, as in the sequence diagram in the architecture chapter.
For destructive actions, the item usually opens a confirmation dialog first ("Delete 3 files?"), and the dialog's close returns focus to the menu's trigger, because the item is gone. When the delete succeeds, the row and its trigger disappear too, so the app moves focus to a neighboring row, as the dropdown-select guide shows.
Think first
"How does it look on a phone?"
On narrow screens many products show the same items as a bottom sheet: full width, large touch targets of at least 44 by 44 px, submenus as a drill-down with a Back item. The roles, keys and API stay the same; only the presentation and placement change.
The trigger needs a touch target of at least 24 by 24 CSS px to meet WCAG 2.2 target size minimum (2.5.8), and more is better for a three-dot icon that is easy to miss.
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-haspopupandaria-expandedon a real button,menuwithmenuitem,menuitemcheckboxandmenuitemradio, 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-disabledso 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
autopopovers 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
pointerdownfor 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.
References
- WAI-ARIA APG: Menu Button Pattern The trigger's roles, states and opening keys, with two example implementations.
- WAI-ARIA APG: Menu and Menubar Pattern Every key inside menus and submenus, disabled items, separators and groups.
- WAI-ARIA APG: Example Disclosure Navigation Menu The pattern to use for site navigation instead of a menu.
- Adrian Roselli: Don't Use ARIA Menu Roles for Site Nav Why menu roles mislead users on websites.
- Heydon Pickering, Inclusive Components: Menus and Menu Buttons When a true menu is right, and how to build one.
- MDN: Popover API Top layer, light dismiss, and the toggle event.
- MDN: CSS anchor positioning Anchors, position-area and fallback options.
- Floating UI: useHover and safePolygon Hover delays and the safe polygon used for submenus.
- Radix UI: Dropdown Menu A production compound-component menu: submenus, check items, typeahead, modal option.
- MDN: aria-keyshortcuts How to announce shortcut keys on menu items.
Concepts behind it
- ARIA widget patternsThe tested recipes for custom widgets from the ARIA Authoring Practices Guide: combobox, listbox, menu, tabs, dialog, tree, grid, disclosure, and accordion, with the roles, states, and keys each one promises, and how to pick the right one.
- Keyboard navigationMaking every action work without a mouse: a Tab order that follows the page, a focus ring you can always see, arrow keys inside composite widgets, skip links past repeated content, and no place where focus gets stuck.
- Focus managementDeciding where keyboard focus goes when the page changes under the user (route changes, modals, deletes, new content) so keyboard and screen reader users never lose their place.
- Screen readers and live regionsHow screen readers turn a page into speech or braille, how every control gets its accessible name and description, and how to announce changes like search results, saves, and errors with live regions.
- Designing component APIsHow to shape a component's props, children, slots, events, and ref so other engineers can use it for cases you didn't predict, without it growing a hundred props: composition, compound components, controlled state, and headless behavior.
- State machines for UIModel a flow as a fixed set of states and the events that move between them, so impossible combinations like "loading and failed at once" can't happen and every button does the right thing in every state.
- Testing accessibilityHow a team proves a UI works for people who use keyboards, screen readers, zoom, and voice control: layers of linting, automated scans, role-based unit tests, manual keyboard and screen reader passes, and real users, measured against WCAG.