Menu
Opens a floating list of actions or options from a trigger element.
Installation
- Install the package.
npm install @hashintel/ds-components- 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 acceptingaria-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.headerandfooter— 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.
Link items
Items with href render real anchors, so they support open-in-new-tab and
middle-click. Add target="_blank" for external destinations.
Submenus
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
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-expandedand a Button trigger also shows its pressed visual. - Selecting an item closes the menu unless that item sets
keepOpenOnSelect. - With an empty
itemsarray, 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
descriptionto 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-haspopupandaria-expanded; closing the menu returns focus to it. - Disabled and loading items are exposed as disabled and skipped by arrow-key navigation.
- Item
icons arearia-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
idwhentextis JSX; the id is also whatonClickandonKeyDownreport.
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab | When 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
Menu
| Prop | Type | Description |
|---|---|---|
| items | ItemOrGroup<MenuItem>[] | |
| trigger | ReactElement<unknown, string | JSXElementConstructor<any>> | |
| position | Position default: "bottom-start" | |
| header | ReactNode | Pinned above the items, outside the scrollable area. Undecorated — supply your own divider if needed. |
| footer | ReactNode | Pinned below the items, outside the scrollable area. Undecorated — supply your own divider if needed. |
| swapHeaderFooterOnFlip | boolean | Whether to swap the header and footer to the opposite edge when the menu opens upward. |
| onOpen | ((open: boolean) => void) | |
| onKeyDown | ((event: KeyboardEvent<Element>, highlightedValue: string | null) => void) | Called with key events from the open menu, plus the highlighted item's id (or null when none). |
MenuItem
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.
| Prop | Type | Description |
|---|---|---|
| description | ReactNode | One line of secondary text under the label |
| icon | "function" | "filter" | "at" | "circle" | … 137 more | Design-system icon before the label — use sparingly, to differentiate important items |
| loading | boolean | Shows a spinner and makes the item inert |
| suffix | ReactNode | Trailing content aligned to the row's end, e.g. a count |
| indent | number | Indents the row by this many steps |
| disabled | boolean | Disables the item; arrow keys skip it |
| tone | "neutral" | "brand" | "error" | error marks destructive actions |
| selectedStyle | "checkbox" | "tick" | "highlight" default: "highlight" | How a selected item is shown |
| selectedTone | "neutral" | "brand" | "error" | Tone of the selected indicator, when it differs from tone |
| keepOpenOnSelect | boolean | Keeps the menu open when this item is selected |
| id | string | Identifies the item, e.g. in onClick — required when text is not a plain string Identifies the item, e.g. in onClick — a plain-string text doubles as the id |
| text | ReactNode | The row's label The row's label; a plain string doubles as the item's id |
| href | string | Renders the item as a link to this URL — one of href, onClick, or subItems per item |
| target | "_blank" | Opens the link in a new tab |
| onClick | ((id: string) => void) | Called with the item's id when it is activated |
| subItems | ItemOrGroup<Item>[] | Opens a nested submenu of these entries |
| menuClassName | string | Applied to this item's submenu content, composed with the list's own class, for styling that only that submenu should carry. |
| custom | ReactNode | A row of arbitrary content — never highlighted or selectable, though Tab reaches focusable children. Mutually exclusive with every other field. |
| selected | boolean | Whether the item is marked as the current choice. |
MenuItemGroup
A labelled group of items, usable wherever items are: { id, label, items }.
| Prop | Type | Description |
|---|---|---|
| id | string | Identifies the group |
| label | ReactNode | Rendered as an uppercase label above the group's items |
| items | MenuItem[] | The group's entries — items and custom rows; groups don't nest |
EllipsisMenu
Accepts every Menu prop except trigger, plus the trigger-styling props
below.
| Prop | Type | Description |
|---|---|---|
| items | ItemOrGroup<MenuItem>[] | |
| position | Position default: "bottom-end" | |
| onKeyDown | ((event: KeyboardEvent<Element>, highlightedValue: string | null) => void) | Called with key events from the open menu, plus the highlighted item's id (or null when none). |
| onOpen | ((open: boolean) => void) | |
| header | ReactNode | Pinned above the items, outside the scrollable area. Undecorated — supply your own divider if needed. |
| footer | ReactNode | Pinned below the items, outside the scrollable area. Undecorated — supply your own divider if needed. |
| swapHeaderFooterOnFlip | boolean | Whether to swap the header and footer to the opposite edge when the menu opens upward. |
| 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?