Menu
A dropdown of actions triggered from a button or other element. For value selection, use Select or ComboBox.
import type { Selection } from "react-aria-components"import { useState } from "react"import { MoreHorizontal } from "lucide-react"import { Button, Menu, MenuItem, MenuSection, MenuTrigger, SubmenuTrigger,} from "@opengovsg/oui"export const Example = () => { const [style, setStyle] = useState<Selection>(new Set(["bold", "italic"])) const [align, setAlign] = useState<Selection>(new Set(["left"])) return ( <MenuTrigger> <Button isIconOnly variant="outline" className="px-2"> <MoreHorizontal className="h-5 w-5" /> </Button> <Menu> <MenuSection title="Actions"> <SubmenuTrigger> <MenuItem id="open">Open</MenuItem> <Menu> <MenuItem id="open-new">Open in New Window</MenuItem> <MenuItem id="open-current">Open in Current Window</MenuItem> <SubmenuTrigger> <MenuItem id="more">More</MenuItem> <Menu> <MenuItem id="open-email">Open in Email Client</MenuItem> <MenuItem id="open-in-alt"> Open in Alternative Browser </MenuItem> </Menu> </SubmenuTrigger> </Menu> </SubmenuTrigger> <MenuItem>Paste</MenuItem> </MenuSection> <MenuSection selectionMode="multiple" selectedKeys={style} onSelectionChange={setStyle} title="Text style" > <MenuItem id="bold">Bold</MenuItem> <MenuItem id="italic">Italic</MenuItem> <MenuItem id="underline">Underline</MenuItem> </MenuSection> <MenuSection selectionMode="single" selectedKeys={align} onSelectionChange={setAlign} title="Text alignment" > <MenuItem id="left">Left</MenuItem> <MenuItem id="center">Center</MenuItem> <MenuItem id="right">Right</MenuItem> </MenuSection> </Menu> </MenuTrigger> )}Usage
Use Menu for lists of actions (e.g., "Edit", "Delete", "Share"). Use Select when the user is choosing a value to submit. Use ComboBox for value selection with filtering.
import {
Menu,
MenuItem,
MenuSection,
MenuSeparator,
MenuTrigger,
SubmenuTrigger,
} from "@opengovsg/oui"<MenuTrigger>
<Button>Options</Button>
<Menu>
<MenuItem id="edit">Edit</MenuItem>
<MenuItem id="copy">Copy</MenuItem>
<MenuItem id="delete">Delete</MenuItem>
</Menu>
</MenuTrigger>Alternatively, install the component as local source via the shadcn CLI:
npx shadcn@latest add https://oui.open.gov.sg/r/menu.jsonpnpm dlx shadcn@latest add https://oui.open.gov.sg/r/menu.jsonnpx shadcn@latest add https://oui.open.gov.sg/r/menu.jsonbunx --bun shadcn@latest add https://oui.open.gov.sg/r/menu.jsonMenu renders a floating list of actions, built on React Aria's Menu. It handles keyboard navigation, focus management, selection state, and ARIA semantics automatically. The menu is opened and closed by a MenuTrigger wrapper (re-exported from react-aria-components).
If the trigger element does not have a visible label, pass aria-label to the trigger element to identify it to assistive technology.
Anatomy
Menu consists of:
- MenuTrigger (from
react-aria-components— provides open/close state)- Trigger element (e.g.,
Button) - Menu — the floating list container
- MenuItem — one action per item
- MenuSection (optional) — groups items with a heading
- MenuSeparator (optional) — a visual divider between items
- SubmenuTrigger (optional, from
react-aria-components) — wraps aMenuItemand a nestedMenu
- Trigger element (e.g.,
Examples
With Icons
Use the startContent and endContent props on MenuItem to add icons or badges alongside the item label.
import { Copy, Edit, Share, Trash2 } from "lucide-react"import { Button, Menu, MenuItem, MenuTrigger } from "@opengovsg/oui"export const Example = () => { return ( <MenuTrigger> <Button variant="outline">Actions</Button> <Menu> <MenuItem id="edit" startContent={<Edit className="h-4 w-4" />}> Edit </MenuItem> <MenuItem id="copy" startContent={<Copy className="h-4 w-4" />}> Copy </MenuItem> <MenuItem id="share" startContent={<Share className="h-4 w-4" />}> Share </MenuItem> <MenuItem id="delete" startContent={<Trash2 className="h-4 w-4 text-red-500" />} > Delete </MenuItem> </Menu> </MenuTrigger> )}Disabled Items
Use disabledKeys on Menu to prevent specific items from being activated.
Each item must have a unique
idfordisabledKeysto work correctly.
import { Button, Menu, MenuItem, MenuTrigger } from "@opengovsg/oui"export const Example = () => { return ( <MenuTrigger> <Button variant="outline">File</Button> <Menu disabledKeys={["save", "print"]}> <MenuItem id="new">New…</MenuItem> <MenuItem id="open">Open…</MenuItem> <MenuItem id="save">Save</MenuItem> <MenuItem id="print">Print…</MenuItem> </Menu> </MenuTrigger> )}With Sections
Use MenuSection to group related items under a shared heading. Pass title for a visible heading, or aria-label when a heading is not shown visually.
import { Button, Menu, MenuItem, MenuSection, MenuSeparator, MenuTrigger,} from "@opengovsg/oui"export const Example = () => { return ( <MenuTrigger> <Button variant="outline">Edit</Button> <Menu> <MenuSection title="Clipboard"> <MenuItem id="cut">Cut</MenuItem> <MenuItem id="copy">Copy</MenuItem> <MenuItem id="paste">Paste</MenuItem> </MenuSection> <MenuSeparator /> <MenuSection title="Transform"> <MenuItem id="bold">Bold</MenuItem> <MenuItem id="italic">Italic</MenuItem> <MenuItem id="underline">Underline</MenuItem> </MenuSection> </Menu> </MenuTrigger> )}Selection
MenuSection supports selectionMode="single" and selectionMode="multiple". Use selectedKeys / onSelectionChange to control selection state. Selected items display a check icon by default.
import type { Selection } from "react-aria-components"import { useState } from "react"import { Button, Menu, MenuItem, MenuSection, MenuTrigger,} from "@opengovsg/oui"export const Example = () => { const [size, setSize] = useState<Selection>(new Set(["md"])) const [style, setStyle] = useState<Selection>(new Set(["bold"])) return ( <MenuTrigger> <Button variant="outline">Format</Button> <Menu> <MenuSection title="Font size" selectionMode="single" selectedKeys={size} onSelectionChange={setSize} > <MenuItem id="xs">Extra small</MenuItem> <MenuItem id="sm">Small</MenuItem> <MenuItem id="md">Medium</MenuItem> <MenuItem id="lg">Large</MenuItem> </MenuSection> <MenuSection title="Text style" selectionMode="multiple" selectedKeys={style} onSelectionChange={setStyle} > <MenuItem id="bold">Bold</MenuItem> <MenuItem id="italic">Italic</MenuItem> <MenuItem id="underline">Underline</MenuItem> </MenuSection> </Menu> </MenuTrigger> )}Nested Menus
Use SubmenuTrigger to wrap a MenuItem and a nested Menu when an action has sub-actions.
import type { Selection } from "react-aria-components"import { useState } from "react"import { MoreHorizontal } from "lucide-react"import { Button, Menu, MenuItem, MenuSection, MenuTrigger, SubmenuTrigger,} from "@opengovsg/oui"export const Example = () => { const [style, setStyle] = useState<Selection>(new Set(["bold", "italic"])) const [align, setAlign] = useState<Selection>(new Set(["left"])) return ( <MenuTrigger> <Button isIconOnly variant="outline" className="px-2"> <MoreHorizontal className="h-5 w-5" /> </Button> <Menu> <MenuSection title="Actions"> <SubmenuTrigger> <MenuItem id="open">Open</MenuItem> <Menu> <MenuItem id="open-new">Open in New Window</MenuItem> <MenuItem id="open-current">Open in Current Window</MenuItem> <SubmenuTrigger> <MenuItem id="more">More</MenuItem> <Menu> <MenuItem id="open-email">Open in Email Client</MenuItem> <MenuItem id="open-in-alt"> Open in Alternative Browser </MenuItem> </Menu> </SubmenuTrigger> </Menu> </SubmenuTrigger> <MenuItem>Paste</MenuItem> </MenuSection> <MenuSection selectionMode="multiple" selectedKeys={style} onSelectionChange={setStyle} title="Text style" > <MenuItem id="bold">Bold</MenuItem> <MenuItem id="italic">Italic</MenuItem> <MenuItem id="underline">Underline</MenuItem> </MenuSection> <MenuSection selectionMode="single" selectedKeys={align} onSelectionChange={setAlign} title="Text alignment" > <MenuItem id="left">Left</MenuItem> <MenuItem id="center">Center</MenuItem> <MenuItem id="right">Right</MenuItem> </MenuSection> </Menu> </MenuTrigger> )}Sizes
Use the size prop on Menu to change the density. Defaults to "md".
import { Button, Menu, MenuItem, MenuTrigger } from "@opengovsg/oui"export const Example = () => { return ( <div className="flex gap-4"> {(["xs", "sm", "md"] as const).map((size) => ( <MenuTrigger key={size}> <Button variant="outline" size={size}> {size.toUpperCase()} </Button> <Menu size={size}> <MenuItem id="new">New</MenuItem> <MenuItem id="open">Open</MenuItem> <MenuItem id="save">Save</MenuItem> </Menu> </MenuTrigger> ))} </div> )}Flipping within a scroll container
By default the menu flips to stay within the viewport. When the trigger lives inside a scrollable region, pass that region as boundaryElement (and scrollRef) so the menu flips to stay within the container instead of the viewport. Both should resolve to the same scroll container element.
function ScrollableMenu() {
const scrollRef = useRef(null)
const [boundary, setBoundary] = useState(null)
return (
<div
ref={(el) => {
scrollRef.current = el
setBoundary(el)
}}
className="h-60 overflow-y-auto"
>
{/* …scrollable content… */}
<MenuTrigger>
<Button>Options</Button>
{boundary && (
<Menu boundaryElement={boundary} scrollRef={scrollRef}>
<MenuItem id="edit">Edit</MenuItem>
<MenuItem id="delete">Delete</MenuItem>
</Menu>
)}
</MenuTrigger>
</div>
)
}Content
Menu follows the Collection Components API, accepting both static and dynamic collections.
Static collections pass children directly when the list of items is known at render time:
<MenuTrigger>
<Button>Actions</Button>
<Menu>
<MenuItem id="edit">Edit</MenuItem>
<MenuItem id="delete">Delete</MenuItem>
</Menu>
</MenuTrigger>Dynamic collections pass an iterable to the items prop and a render function as children. Each item object must have an id field (or provide a unique id prop on the rendered MenuItem).
import { useState } from "react"import { Button, Menu, MenuItem, MenuTrigger } from "@opengovsg/oui"const actions = [ { id: "view", label: "View details" }, { id: "edit", label: "Edit" }, { id: "duplicate", label: "Duplicate" }, { id: "archive", label: "Archive" }, { id: "delete", label: "Delete" },]export const Example = () => { const [lastAction, setLastAction] = useState<string | null>(null) return ( <div className="flex flex-col gap-2"> <MenuTrigger> <Button variant="outline">Actions</Button> <Menu items={actions} onAction={(key) => setLastAction(String(key))}> {(item) => <MenuItem id={item.id}>{item.label}</MenuItem>} </Menu> </MenuTrigger> <p className="text-sm text-gray-600"> Last action: {lastAction ?? "None"} </p> </div> )}Use the id prop on each MenuItem — it is passed to onAction when the item is activated. Use textValue on an item when its children is not a plain string (e.g., when it contains icons), so keyboard typeahead works correctly.
Events
onAction(onMenu) — Called when any item is activated, receiving the item'sKey. Use this as an alternative to puttingonActionon individualMenuItems.onAction(onMenuItem) — Called when this specific item is activated (no argument).onSelectionChange(onMenuSection) — Called when selection changes within a section in single or multiple selection mode, receiving aSelectionset.onOpenChange(onMenuTrigger) — Called when the menu opens or closes, receiving(isOpen: boolean).onClose(onMenu) — Called when the menu should close after an item is selected.onFocus/onBlur— Called when the menu receives or loses focus.
Accessibility
- Menu is built on React Aria's
useMenu/useMenuItemhooks, which produce a nativerole="menu"/role="menuitem"ARIA tree automatically. - Keyboard navigation: Up/Down arrows move between items; Enter or Space activates the focused item; Escape closes the menu and returns focus to the trigger; Tab closes the menu.
- Submenu navigation: Right arrow (or Enter) opens a submenu; Left arrow (or Escape) closes it and returns focus to the parent item.
- The trigger element should have a visible label or an
aria-labelso screen-reader users know what the menu controls. MenuSectionrequires eithertitle(rendered as a visible heading) oraria-labelto label the group for assistive technology.disabledKeysmarks items asaria-disabled— they remain visible but cannot be activated.
Slots
Slots are named regions of the component you can target with custom Tailwind classes via the classNames prop. Each slot below corresponds to a key on the classNames object.
Menu slots:
base: The scrollable<ul>list element inside the popover.popover: The floating popover container that wraps the menu.separator: The<hr>element rendered byMenuSeparator.
MenuItem slots (via MenuItem's classNames):
container: The list item row — the full clickable area.label: The primary text label inside the item.description: Secondary descriptive text (inherited fromlistBoxItemStyles).iconContainer: The fixed-width span that wraps the selection check icon.icon: The selection check or submenu chevron icon element.
MenuSection slots (via MenuSection's classNames):
base: The<section>wrapper around the group.header: The heading element rendered above the group's items whentitleis set.
Custom Styles
import { Button, Menu, MenuItem, MenuTrigger } from "@opengovsg/oui"export const Example = () => { return ( <MenuTrigger> <Button variant="outline">Options</Button> <Menu classNames={{ base: "rounded-xl border border-blue-200 bg-blue-50", popover: "shadow-xl", }} > <MenuItem id="profile" classNames={{ container: "hover:bg-blue-100", label: "font-semibold text-blue-900", }} > Profile </MenuItem> <MenuItem id="settings" classNames={{ container: "hover:bg-blue-100", label: "font-semibold text-blue-900", }} > Settings </MenuItem> <MenuItem id="logout" classNames={{ container: "hover:bg-red-100", label: "font-semibold text-red-700", }} > Log out </MenuItem> </Menu> </MenuTrigger> )}Props
Menu
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((item: T) => ReactNode) | - | Static children or render function for dynamic items |
items | Iterable<T> | - | The list of items for a dynamic collection |
disabledKeys | Iterable<Key> | - | Keys of items that are disabled |
selectionMode | "none" | "single" | "multiple" | "none" | The selection mode for the menu (prefer per-section selection) |
selectedKeys | Selection | - | The currently selected keys (controlled) |
defaultSelectedKeys | Selection | - | The default selected keys (uncontrolled) |
onSelectionChange | (keys: Selection) => void | - | Called when the selection changes |
onAction | (key: Key) => void | - | Called when an item is activated, receiving its Key |
onClose | () => void | - | Called when the menu should close after item selection |
size | "xs" | "sm" | "md" | "md" | The size (density) of the menu and its items |
placement | Placement | - | The placement of the popover relative to the trigger |
boundaryElement | Element | body | Element the popover is positioned within — bounds where it flips (e.g. a scroll container) |
scrollRef | RefObject<Element> | - | The scrollable region the popover anchors to; pair with boundaryElement for a bounded container |
shouldFlip | boolean | true | Whether the popover flips to the opposite side when it would overflow the boundary |
offset | number | 8 | Distance between the popover and the trigger |
crossOffset | number | 0 | Offset along the cross axis |
containerPadding | number | 12 | Minimum padding between the popover and the boundary edges |
maxHeight | number | - | The maximum height of the popover |
classNames | SlotsToClasses<"base" | "popover" | "separator"> | - | Custom Tailwind classes for menu slots |
autoFocus | boolean | "first" | "last" | - | Where focus is placed when the menu opens |
placement, boundaryElement, scrollRef, shouldFlip, offset, crossOffset, containerPadding, and maxHeight are forwarded to the underlying Popover. All remaining props are inherited from React Aria's Menu (notably shouldFocusWrap, escapeKeyBehavior, aria-label, aria-labelledby).
MenuItem
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((renderProps: MenuItemRenderProps) => ReactNode) | - | The item content, or a render function receiving render props |
id | Key | - | A unique identifier for the item; passed to onAction and used by disabledKeys |
textValue | string | - | Plain-text value for typeahead; required when children is not a string |
isDisabled | boolean | false | Whether the item is disabled |
startContent | ReactNode | - | Element rendered to the left of the item label (e.g., an icon) |
endContent | ReactNode | - | Element rendered to the right of the item label (e.g., a badge or keyboard shortcut) |
multipleSelectionIcon | ReactNode | null | - | Custom icon shown when the item is selected in multiple selection mode; null hides it |
singleSelectionIcon | ReactNode | null | - | Custom icon shown when the item is selected in single selection mode; null hides it |
onAction | () => void | - | Called when this specific item is activated |
classNames | SlotsToClasses<"container" | "label" | "description" | "iconContainer" | "icon"> | - | Custom Tailwind classes for item slots |
Inherits all remaining props from React Aria's MenuItem. Notable ones: href, target, rel (router link support), aria-label.
MenuSection
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | ((item: T) => ReactNode) | - | Static children or render function for dynamic items within this section |
items | T[] | - | The list of items for a dynamic collection in this section |
title | string | - | A visible heading rendered above the section's items; mutually exclusive with aria-label |
selectionMode | "none" | "single" | "multiple" | "none" | The selection mode for items within this section |
selectedKeys | Selection | - | The currently selected keys within this section (controlled) |
defaultSelectedKeys | Selection | - | The default selected keys within this section (uncontrolled) |
onSelectionChange | (keys: Selection) => void | - | Called when the selection within this section changes |
classNames | SlotsToClasses<"base" | "header"> | - | Custom Tailwind classes for section slots |
Inherits all remaining props from React Aria's Section. Notable ones: aria-label (required when title is not provided).
MenuTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactNode | - | The trigger element and the Menu component |
isOpen | boolean | - | Whether the menu is open (controlled) |
defaultOpen | boolean | false | Whether the menu is initially open (uncontrolled) |
onOpenChange | (isOpen: boolean) => void | - | Called when the menu opens or closes |
trigger | "press" | "longPress" | "press" | How the trigger opens the menu |
MenuSeparator
MenuSeparator accepts all standard HTML <hr> props (e.g., className).
SubmenuTrigger
| Prop | Type | Default | Description |
|---|---|---|---|
children | ReactElement[] | - | The trigger MenuItem and the submenu Menu |
delay | number | 200 | Delay in milliseconds before the submenu opens on hover |