Open UI

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.json
pnpm dlx shadcn@latest add https://oui.open.gov.sg/r/menu.json
npx shadcn@latest add https://oui.open.gov.sg/r/menu.json
bunx --bun shadcn@latest add https://oui.open.gov.sg/r/menu.json

Menu 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 a MenuItem and a nested Menu

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 id for disabledKeys to 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 (on Menu) — Called when any item is activated, receiving the item's Key. Use this as an alternative to putting onAction on individual MenuItems.
  • onAction (on MenuItem) — Called when this specific item is activated (no argument).
  • onSelectionChange (on MenuSection) — Called when selection changes within a section in single or multiple selection mode, receiving a Selection set.
  • onOpenChange (on MenuTrigger) — Called when the menu opens or closes, receiving (isOpen: boolean).
  • onClose (on Menu) — 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 / useMenuItem hooks, which produce a native role="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-label so screen-reader users know what the menu controls.
  • MenuSection requires either title (rendered as a visible heading) or aria-label to label the group for assistive technology.
  • disabledKeys marks items as aria-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 by MenuSeparator.

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 from listBoxItemStyles).
  • 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 when title is 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

PropTypeDefaultDescription
childrenReactNode | ((item: T) => ReactNode)-Static children or render function for dynamic items
itemsIterable<T>-The list of items for a dynamic collection
disabledKeysIterable<Key>-Keys of items that are disabled
selectionMode"none" | "single" | "multiple""none"The selection mode for the menu (prefer per-section selection)
selectedKeysSelection-The currently selected keys (controlled)
defaultSelectedKeysSelection-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
placementPlacement-The placement of the popover relative to the trigger
boundaryElementElementbodyElement the popover is positioned within — bounds where it flips (e.g. a scroll container)
scrollRefRefObject<Element>-The scrollable region the popover anchors to; pair with boundaryElement for a bounded container
shouldFlipbooleantrueWhether the popover flips to the opposite side when it would overflow the boundary
offsetnumber8Distance between the popover and the trigger
crossOffsetnumber0Offset along the cross axis
containerPaddingnumber12Minimum padding between the popover and the boundary edges
maxHeightnumber-The maximum height of the popover
classNamesSlotsToClasses<"base" | "popover" | "separator">-Custom Tailwind classes for menu slots
autoFocusboolean | "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).

PropTypeDefaultDescription
childrenReactNode | ((renderProps: MenuItemRenderProps) => ReactNode)-The item content, or a render function receiving render props
idKey-A unique identifier for the item; passed to onAction and used by disabledKeys
textValuestring-Plain-text value for typeahead; required when children is not a string
isDisabledbooleanfalseWhether the item is disabled
startContentReactNode-Element rendered to the left of the item label (e.g., an icon)
endContentReactNode-Element rendered to the right of the item label (e.g., a badge or keyboard shortcut)
multipleSelectionIconReactNode | null-Custom icon shown when the item is selected in multiple selection mode; null hides it
singleSelectionIconReactNode | 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
classNamesSlotsToClasses<"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.

PropTypeDefaultDescription
childrenReactNode | ((item: T) => ReactNode)-Static children or render function for dynamic items within this section
itemsT[]-The list of items for a dynamic collection in this section
titlestring-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
selectedKeysSelection-The currently selected keys within this section (controlled)
defaultSelectedKeysSelection-The default selected keys within this section (uncontrolled)
onSelectionChange(keys: Selection) => void-Called when the selection within this section changes
classNamesSlotsToClasses<"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).

PropTypeDefaultDescription
childrenReactNode-The trigger element and the Menu component
isOpenboolean-Whether the menu is open (controlled)
defaultOpenbooleanfalseWhether 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 accepts all standard HTML <hr> props (e.g., className).

PropTypeDefaultDescription
childrenReactElement[]-The trigger MenuItem and the submenu Menu
delaynumber200Delay in milliseconds before the submenu opens on hover

On this page