Menu

Opens a floating list of actions or options from a trigger element.

Installation

  1. Install the package.
npm install @hashintel/ds-components
  1. Import the component.
import { Button, Menu } from "@hashintel/ds-components";

export const EntityActionsMenu = () => (
  <Menu
    trigger={
      <Button variant="subtle" iconName="chevronDown" iconPosition="right">
        Entity actions
      </Button>
    }
    items={[
      { text: "Edit entity", onClick: () => {} },
      { text: "Duplicate entity", onClick: () => {} },
    ]}
  />
);

When to use

  • Do: Use Menu to collect secondary actions on an object — edit, duplicate, archive — behind one trigger, where showing them all as buttons would crowd the view.
  • Do: Use EllipsisMenu for per-row actions in tables, cards, and list rows — it is the standard overflow trigger.

When not to use

  • Don't: Don't use Menu to choose a value in a form — use Select, which keeps the chosen value visible in the field.
  • Don't: Don't hide one action behind a menu — show them as Buttons; the extra click buys nothing.
  • Don't: Don't assemble panels or forms out of custom rows — put free-form interactive content in a Popover.

Anatomy

Menu is composed of the following parts:

  • trigger — the element that opens the menu and anchors it: any element accepting aria-expanded, usually a Button.
  • items — the menu's content: action (onClick), link (href), and submenu (subItems) items, mixed freely with labelled groups and custom rows.
  • header and footer — pinned above and below the items, outside the scrollable area, for context or persistent actions.
<Menu
  trigger={<Button>Entity actions</Button>} // opens and anchors the menu
  header={<strong>Alice Chen</strong>} // pinned above the items
  items={[
    { text: "Rename", onClick: rename }, // an action
    { text: "View history", href: historyUrl }, // a link
    {
      text: "Move to workspace", // a submenu
      subItems: [{ text: "Acme Corp", onClick: moveEntity }],
    },
    {
      id: "danger",
      label: "Danger", // a labelled group — dividers draw between groups
      items: [{ text: "Delete entity", tone: "error", onClick: confirmDelete }],
    },
  ]}
  footer={<Button variant="link">View all actions</Button>} // pinned below
/>

Examples

Basic

Every item takes text plus exactly one of onClick, href, or subItems. A plain-string text doubles as the item's id.

Icons and descriptions

icon reinforces the label, description adds one line of secondary text, and suffix renders trailing content such as a count. Don't overuse icons in menus — icons should differentiate important menu items, not decorate every row.

Grouped items

A group is { id, label, items }; groups draw their own separators and an uppercase label. Group related actions once a menu passes roughly six items.

Disabled and loading items

Disabled and loading items stay visible but can't be activated, and arrow keys skip them. loading adds a spinner — use it while an item's availability is still being determined.

Destructive items

Set tone="error" on destructive actions, and place them last in a group of their own so they can't be hit by overshooting an adjacent item.

Items with href render real anchors, so they support open-in-new-tab and middle-click. Add target="_blank" for external destinations.

Items with subItems open a nested menu to the side and show a chevron. Avoid more than one level of nesting — deeper trees are hard to traverse with some input devices.

header and footer pin above and below the items, outside the scrollable area. They come undecorated — add your own divider if the design needs one. Set swapHeaderFooterOnFlip to keep the header on the edge nearest the trigger when the menu opens upward.

Custom rows

An entry of { custom } renders arbitrary content on its own row. Arrow keys will skip it — but focusable children inside it stay reachable with Tab.

Placement

position sets the preferred side and alignment relative to the trigger, defaulting to bottom-start. It is a preferred position, and another placement is automatically chosen when the menu won't fit.

EllipsisMenu

EllipsisMenu is Menu with a ready-made icon-button trigger — the standard overflow affordance for table rows and cards.

Behavior

  • The menu's content mounts renders in a portal above the page, preventing most z-index + overflow issues.
  • While open, the trigger carries aria-expanded and a Button trigger also shows its pressed visual.
  • Selecting an item closes the menu unless that item sets keepOpenOnSelect.
  • With an empty items array, Menu renders just the trigger with no menu behind it; EllipsisMenu disables its trigger instead.
  • Arrow keys wrap from the last item to the first (and back) on discrete presses, but a held key stops at the boundary instead of cycling.

Content

  • Label the trigger with the scope of the actions — "Entity actions", not "Menu" or "Options".
  • Write item labels verb-first, 1–3 words, sentence case: "Edit entity", "Duplicate", "Copy entity ID". No punctuation.
  • Name the object when the menu mixes targets — "Delete property" beats "Delete" when nearby items act on the entity. But do not name the object when there are no mixed targets as the label then becomes redundant
  • Keep description to one short clause of consequence or context, not a restatement of the label.
  • Group labels should be 1–2 word nouns: "View", "Manage".
  • Order items by expected frequency of use, and destructive actions last.

Accessibility

Menu implements the WAI-ARIA menu button pattern.

What Menu provides

  • The trigger is wired with aria-haspopup and aria-expanded; closing the menu returns focus to it.
  • Disabled and loading items are exposed as disabled and skipped by arrow-key navigation.
  • Item icons are aria-hidden

What you must do

  • Make every label carry its full meaning without the icon — icons are not announced to screen readers.
  • Provide an explicit id when text is JSX; the id is also what onClick and onKeyDown report.

Keyboard

KeyAction
Tab / Shift+TabWhen the menu has a focusable header, footer, or custom row, moves between those rows and the item list. Past either edge the menu closes — Tab hands focus to the element after the trigger, Shift+Tab to the trigger itself.

API reference

PropTypeDescription
items
ItemOrGroup<MenuItem>[]
trigger
ReactElement<unknown, string |⁠ JSXElementConstructor<any>>
position
Position
default: "bottom-start"
header
ReactNode
footer
ReactNode
swapHeaderFooterOnFlip
boolean
onOpen
((open: boolean) => void)
onKeyDown
((event: KeyboardEvent<Element>, highlightedValue: string |⁠ null) => void)

The shape of items and subItems entries. Each takes text plus exactly one of onClick, href, or subItems — or a lone custom node for a presentational row.

PropTypeDescription
description
ReactNode
icon
"function" |⁠ "filter" |⁠ "at" |⁠ "circle" |⁠ … 137 more
loading
boolean
suffix
ReactNode
indent
number
disabled
boolean
tone
"neutral" |⁠ "brand" |⁠ "error"
selectedStyle
"checkbox" |⁠ "tick" |⁠ "highlight"
default: "highlight"
selectedTone
"neutral" |⁠ "brand" |⁠ "error"
keepOpenOnSelect
boolean
id
string
text
ReactNode
href
string
target
"_blank"
onClick
((id: string) => void)
subItems
ItemOrGroup<Item>[]
menuClassName
string
custom
ReactNode
selected
boolean

A labelled group of items, usable wherever items are: { id, label, items }.

PropTypeDescription
id
string
label
ReactNode
items
MenuItem[]

EllipsisMenu

Accepts every Menu prop except trigger, plus the trigger-styling props below.

PropTypeDescription
items
ItemOrGroup<MenuItem>[]
position
Position
default: "bottom-end"
onKeyDown
((event: KeyboardEvent<Element>, highlightedValue: string |⁠ null) => void)
onOpen
((open: boolean) => void)
header
ReactNode
footer
ReactNode
swapHeaderFooterOnFlip
boolean
iconName
"function" |⁠ "filter" |⁠ "at" |⁠ "circle" |⁠ … 137 more
default: "ellipsis"
variant
"link" |⁠ "solid" |⁠ "subtle" |⁠ "ghost" |⁠ "linkSubtle"
default: "subtle"
size
"xxs" |⁠ "xs" |⁠ "sm" |⁠ "md" |⁠ "lg"
disabled
boolean

Was this page helpful?

On this page