Menu

A trigger and the MenuList of commands it opens. It isn't modal: no focus trap, no scroll lock, nothing laid over the page, so the pointer passes over it and the page stays usable around it.

Demos

For usage examples, visit the component demo page: Menu

Import

import { Menu } from '@soroush.tech/design-system/Menu'

Props

PropTypeDefaultDescription
triggerReactElement-The element that opens the menu - a Button, a Pressable. It is given aria-haspopup, aria-expanded and aria-controls; its own onClick / onKeyDown still run. Required.
childrenReactNode-MenuItem rows and MenuDividers.
isOpenboolean-Controlled open state. When set, the menu only asks to open or close, through onOpen / onClose.
defaultOpenbooleanfalseUncontrolled initial open state.
onOpen() => void-Fired when the menu asks to open.
onClose(event, reason: MenuCloseReason) => void-Fired when the menu asks to close: 'escapeKey', 'select' (a row chosen), 'triggerClick' or 'blur'.
onStep(step: -1 | 1) => void-Fired when Left or Right has nowhere further to go inside the menu, so a menu bar can step to the next menu. Swapped right to left.
onSelect(value: string | number) => void-Fired with a row's value when a row that has one is chosen.
align'start' | 'end''start'Which edge of the trigger the list lines up with. Mirrored right to left.
marginThresholdkeyof Theme['space']2The least room between the list and the window's edge.
elevationPaperElevation (0-24)8Shadow depth of the surface.
borderRadiuskeyof Theme['radii']theme.defaults.surfaceRadius, else squareCorner radius of the list.
layerkeyof Theme['zOrder']'modal'Stacking layer - modal keeps a menu opened in a dialog above it.
emptyLabelReactNode-Shown as one disabled row when the menu has no rows ("Coming soon"); without it, the list stays empty.
colorkeyof Theme['palette']-Accent color for every row.
textColorkeyof Theme['text']-Base text color for every row.
sizekeyof Theme['sizes']-Density token for every row.
denseboolean-Compact vertical padding for every row.
aria-labelstringtrigger's nameNames the list. Without it the trigger names it (aria-labelledby).
classNamestring-Class on the menu's root.
slotProps{ list? }-Props for a part: list is the role="menu" list - and every submenu's - for the app's data-*, class and aria-*; an onKeyDown runs before the list's own. Its role, id and name are the menu's.

Notes

  • Placement. Under the trigger, lined up with its start or end edge; above it when there's no room below; moved only as far as it must to stay marginThreshold inside the window. It follows the trigger on scroll and resize, and is hidden until first placed so it never shows at the wrong spot. It can't grow taller than the window.
  • Not portaled, not Popover. Popover is a modal - it moves focus in, traps it and hides the rest of the page from assistive technology, which suits a dialog and not a menu. The list is fixed in the window instead, inside the menu's box, so closing when focus leaves works and rows are found by their nearest role="menu". An ancestor with a transform, filter or contain - a zoom wrapper, a sliding drawer - makes itself the containing block of position: fixed; the list measures where it landed and moves back by the difference, so it still opens against its trigger. A scaling transform still scales it.
  • Submenus. A MenuItem with submenu opens a nested list beside it, placed and drawn the way the menu's own list is - see MenuItem's submenus. Escape in a submenu closes only that submenu.
  • A menu bar. Hold isOpen for each menu and step between them with onStep.
  • The list renders nothing until the menu opens, so it costs nothing on the server.

Source code

If this page does not answer your question, have a look at the implementation of the component for more detail.