Skip to Content
Hashti Design System
Components

Menu

Actionable choice list in a popover, with sections, single and multiple selection, nested submenus, and an overflow-menu helper.

Menu

A Menu presents a list of actions or options in a lightweight popover attached to a trigger. Use it when the choices are commands that act immediately (view, duplicate, archive) or a small set of options the user picks from, rather than a value bound to a form field.

Hashti re-exports React Aria’s menu parts and styles them: MenuTrigger, Menu, MenuItem, MenuSection, SubmenuTrigger, plus React Aria’s Separator and Header as SectionHeader. A small OverflowMenu helper wraps the common “⋯” trigger case in a single component.

Menus are for transient choices. For a form value use Select; for navigation with persistent structure use a Tree or Navigation Tree.

MenuAction menu with a separator and a destructive item.
Last action: None

Import

import {
  MenuTrigger,
  Menu,
  MenuItem,
  MenuSection,
  SectionHeader,
  Separator,
  SubmenuTrigger,
  Popover,
  OverflowMenu,
} from "@hashti/react";

Anatomy

<MenuTrigger>
  <Button>Repository actions</Button>
  <Popover>
    <Menu onAction={(key) => console.log(key)}>
      <MenuItem id="view">View details</MenuItem>
      <Separator />
      <MenuItem id="delete" data-danger>
        Delete repository
      </MenuItem>
    </Menu>
  </Popover>
</MenuTrigger>

MenuTrigger expects exactly two children: the trigger and the popover. Menu requires an accessible name — pass aria-label or aria-labelledby unless a MenuSection header already labels the whole list.


Examples

Action Menu

onAction receives the activated item’s key. Focus returns to the trigger after activation and after Escape.

import { useState } from "react";
import { Button, Menu, MenuItem, MenuTrigger, Popover, Separator } from "@hashti/react";

export function ActionMenuExample() {
  const [lastAction, setLastAction] = useState("None");

  return (
    <div className="flex items-center gap-4">
      <MenuTrigger>
        <Button variant="secondary">Actions</Button>
        <Popover>
          <Menu onAction={(key) => setLastAction(String(key))}>
            <MenuItem id="view">View details</MenuItem>
            <MenuItem id="duplicate">Duplicate repository</MenuItem>
            <MenuItem id="archive">Archive repository</MenuItem>
            <Separator />
            <MenuItem id="delete" data-danger>
              Delete repository
            </MenuItem>
          </Menu>
        </Popover>
      </MenuTrigger>
      <span className="text-sm text-neutral-500">Selected: {lastAction}</span>
    </div>
  );
}

data-danger is the supported way to mark a destructive item: it is a plain data attribute styled by the menu rules (--hashti-support-error text and tinted hover), not a prop.

Sections and Disabled Items

Wrap groups in MenuSection with a SectionHeader (the Header part, aliased) and separate groups with Separator. disabledKeys disables items by key while keeping them announced.

import {
  Button,
  Menu,
  MenuItem,
  MenuSection,
  MenuTrigger,
  Popover,
  SectionHeader,
  Separator,
} from "@hashti/react";

export function SectionedMenuExample() {
  return (
    <MenuTrigger>
      <Button>File menu</Button>
      <Popover>
        <Menu aria-label="File" disabledKeys={["paste"]}>
          <MenuSection>
            <SectionHeader>Edit options</SectionHeader>
            <MenuItem id="cut">Cut</MenuItem>
            <MenuItem id="copy">Copy</MenuItem>
            <MenuItem id="paste">Paste</MenuItem>
          </MenuSection>
          <Separator />
          <MenuSection>
            <SectionHeader>Export formats</SectionHeader>
            <MenuItem id="pdf">Download PDF</MenuItem>
            <MenuItem id="csv">Export CSV</MenuItem>
          </MenuSection>
        </Menu>
      </Popover>
    </MenuTrigger>
  );
}

Single and Multiple Selection

selectionMode="single" renders items as menuitemradio, selectionMode="multiple" as menuitemcheckbox with aria-checked. Selection state is controlled with selectedKeys or uncontrolled with defaultSelectedKeys (a RangeValue/Iterable<Key>).

import { useState } from "react";
import { Button, Menu, MenuItem, MenuTrigger, Popover, type Selection } from "@hashti/react";

export function SelectionMenuExample() {
  const [sort, setSort] = useState<Selection>(new Set(["date"]));
  const [columns, setColumns] = useState<Selection>(new Set(["status", "reviewer"]));

  return (
    <div className="flex flex-wrap gap-3">
      <MenuTrigger>
        <Button variant="secondary">Sort by</Button>
        <Popover>
          <Menu
            aria-label="Sort order"
            selectionMode="single"
            selectedKeys={sort}
            onSelectionChange={setSort}
          >
            <MenuItem id="name">Sort by name</MenuItem>
            <MenuItem id="date">Sort by date</MenuItem>
            <MenuItem id="priority">Sort by priority</MenuItem>
          </Menu>
        </Popover>
      </MenuTrigger>

      <MenuTrigger>
        <Button variant="secondary">Visible columns</Button>
        <Popover>
          <Menu
            aria-label="Visible columns"
            selectionMode="multiple"
            selectedKeys={columns}
            onSelectionChange={setColumns}
          >
            <MenuItem id="status">Status column</MenuItem>
            <MenuItem id="reviewer">Reviewer column</MenuItem>
            <MenuItem id="branch">Branch column</MenuItem>
          </Menu>
        </Popover>
      </MenuTrigger>
    </div>
  );
}

Nest a SubmenuTrigger around a MenuItem plus its own Popover + Menu. Submenus open on hover after a short delay, on click, and with →.

import { Button, Menu, MenuItem, MenuTrigger, Popover, SubmenuTrigger } from "@hashti/react";

export function SubmenuExample() {
  return (
    <MenuTrigger>
      <Button>Tools</Button>
      <Popover>
        <Menu aria-label="Tools">
          <MenuItem id="quick">Quick search</MenuItem>
          <SubmenuTrigger>
            <MenuItem id="more">More tools</MenuItem>
            <Popover>
              <Menu aria-label="More tools">
                <MenuItem id="profiler">Memory profiler</MenuItem>
                <MenuItem id="network">Network inspector</MenuItem>
                <MenuItem id="audit">Lighthouse audit</MenuItem>
              </Menu>
            </Popover>
          </SubmenuTrigger>
          <MenuItem id="settings">Preferences</MenuItem>
        </Menu>
      </Popover>
    </MenuTrigger>
  );
}

Keyboard Shortcuts in Items

There is no shortcut prop — render a Keyboard inside the item. It is presentational; you still have to bind the key handler yourself.

import { Button, Keyboard, Menu, MenuItem, MenuTrigger, Popover } from "@hashti/react";

export function ShortcutMenuExample() {
  return (
    <MenuTrigger>
      <Button>Edit</Button>
      <Popover>
        <Menu aria-label="Edit">
          <MenuItem id="cut">
            Cut <Keyboard>⌘X</Keyboard>
          </MenuItem>
          <MenuItem id="copy">
            Copy <Keyboard>⌘C</Keyboard>
          </MenuItem>
        </Menu>
      </Popover>
    </MenuTrigger>
  );
}

Overflow Menu

OverflowMenu renders the ghost icon trigger, popover, and Menu for you. It requires an aria-label for the trigger button.

import { MenuItem, OverflowMenu, Separator } from "@hashti/react";

export function OverflowMenuExample() {
  return (
    <OverflowMenu aria-label="Instance actions" onAction={(key) => console.log(key)}>
      <MenuItem id="restart">Restart instance</MenuItem>
      <MenuItem id="snapshot">Create snapshot</MenuItem>
      <Separator />
      <MenuItem id="terminate" data-danger>
        Terminate instance
      </MenuItem>
    </OverflowMenu>
  );
}

Props Reference

PropTypeDefaultDescription
selectionMode'none' | 'single' | 'multiple''none'Item role and toggle behaviour.
selectedKeys'all' | Iterable<Key>—Controlled selection.
defaultSelectedKeys'all' | Iterable<Key>—Uncontrolled initial selection.
onSelectionChange(keys: Selection) => void—Fired when selection changes.
disabledKeysIterable<Key>—Keys that cannot be activated or selected.
onAction(key: Key) => void—Fired when an item is activated.
onClose() => void—Fired when the menu requests to close.
shouldCloseOnSelectbooleantrue for none/single, false for multipleWhether activation closes the menu.
aria-labelstring—Accessible name for the menu (use when no section header labels it).
PropTypeDefaultDescription
idKey—Identity used by onAction, disabledKeys, and selection.
textValuestring—Overrides the string used for typeahead and screen readers.
isDisabledbooleanfalseDisables this item.
hrefstring—Renders the item as a link.
data-dangerboolean—Data attribute, not a prop: use data-danger for destructive styling.

OverflowMenu

PropTypeDefaultDescription
aria-labelstring—Required. Accessible name for the trigger.
childrenReactNode—MenuItem, MenuSection, and Separator children.
onAction(key: Key) => void—Forwarded to the inner Menu.
disabledKeysIterable<Key>—Forwarded to the inner Menu.
size'sm' | 'md' | 'lg''md'Trigger button size.
classNamestring—Class names for the trigger button.
popoverClassNamestring—Class names for the popover.

What this component does not do

  • No menu-level accessible name by default. Menu renders whatever aria-label you give it; only MenuSection headers label groups.
  • No automatic positioning tuning. The popover uses React Aria’s collision handling with Hashti surface styling; you do not get per-item placement options beyond Popover’s own props.
  • No shortcut binding. Keyboard inside an item is a visual hint only.
  • No custom item rendering. MenuItem renders text plus whatever children you put inside; there is no slot system for leading icons beyond inserting your own element.

Accessibility

  • The popover contains a role="menu"; items are menuitem, menuitemradio (selectionMode="single"), or menuitemcheckbox (selectionMode="multiple") with aria-checked.
  • Sections render as role="group" labelled by their SectionHeader; Separator renders role="separator".
  • Opening the menu moves focus to the first (or selected) item. Closing with Escape or by activating an item returns focus to the trigger; Tab moves focus out of the menu, which closes it.
  • Focus is contained inside the open menu until it closes; disabled items stay in the list but are announced as unavailable (aria-disabled) and skipped by arrow navigation.
  • Submenus are labelled by their parent item and get their own focus management; ← returns focus to the parent item.
  • The trigger must have a name. With a text Button this is automatic; with OverflowMenu you must pass aria-label.

Keyboard Interactions

KeyAction
Space / EnterOn the trigger, opens the menu; on an item, activates it.
↓ / ↑Moves focus to the next or previous item.
Home / EndMoves focus to the first or last item.
Letter keysTypeahead to the next item matching the typed text.
→Opens the focused item’s submenu.
←Closes the submenu and returns focus to its parent item.
EscapeCloses the menu and returns focus to the trigger.
Space (selection mode)Toggles a menuitemcheckbox; selects a menuitemradio.

WAI-ARIA & Data Attributes

AttributeTargetDescription
[data-selected]MenuItemPresent when the item is selected.
[data-focused]MenuItemPresent when the item has focus.
[data-hovered]MenuItemPresent on pointer hover.
[data-disabled]MenuItemPresent when the item is disabled.
[data-danger]MenuItemDestructive styling; add it yourself.
[data-has-submenu]MenuItemPresent on an item that opens a submenu.