Menu

The list of things you can do to something.

The list of things you can do to something.

A menu is not a select, and the difference is what the rows are. A select's rows are values: picking one answers a question the form asked, and the trigger then shows the answer. A menu's rows are verbs — rename, duplicate, delete — and the trigger goes on saying the same thing afterwards, because nothing about it was chosen.

The panel is a Popover underneath, so a menu inherits its measuring, flipping and edge-clamping rather than owning a second copy of them. A menu near the bottom of the screen opens upwards, and presentation="bottom-sheet" moves the same rows into a sheet.

Installation

Menu ships with the library — no separate install.

import { Menu, Button, PencilIcon, TrashIcon, PackageIcon, ShareNodesIcon, PlusSquareIcon, DownloadIcon, LockIcon, EllipsisIcon } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add menu

Usage

<Menu>
  <Menu.Trigger>
    <Button variant="outline">Options</Button>
  </Menu.Trigger>
  <Menu.Content align="start" width={224}>
    <Menu.Item icon={<PencilIcon size={16} />} shortcut="⌘R">
      Rename
    </Menu.Item>
    <Menu.Separator />
    <Menu.Item variant="destructive" icon={<TrashIcon size={16} />}>
      Delete
    </Menu.Item>
  </Menu.Content>
</Menu>

Composition

<Menu>
  <Menu.Trigger>…</Menu.Trigger>
  <Menu.Content>
    <Menu.Label>…</Menu.Label>
    <Menu.Item>…</Menu.Item>
    <Menu.CheckboxItem>…</Menu.CheckboxItem>
    <Menu.RadioGroup>
      <Menu.RadioItem>…</Menu.RadioItem>
    </Menu.RadioGroup>
    <Menu.Separator />
    <Menu.Sub>
      <Menu.SubTrigger>…</Menu.SubTrigger>
      <Menu.SubContent>…</Menu.SubContent>
    </Menu.Sub>
  </Menu.Content>
</Menu>
  • Menu.Trigger — Wraps a single child and opens the menu on press. It is also what gets measured, so the panel knows where to sit.
  • Menu.Content — The panel, and what a screen reader announces as a menu. Portaled above everything else, positioned against the trigger, capped to the safe area and scrolled.
  • Menu.Label — Non-interactive heading over a run of rows.
  • Menu.Item — One row. Dismisses the menu once it has run, unless closeOnSelect says otherwise.
  • Menu.CheckboxItem — A row carrying a state rather than an action. Keeps the menu open by default.
  • Menu.RadioGroup — A run of rows of which exactly one is chosen.
  • Menu.RadioItem — One option inside a Menu.RadioGroup.
  • Menu.Separator — Hairline between two runs of rows.
  • Menu.Sub — Groups a Menu.SubTrigger with the rows it reveals.
  • Menu.SubTrigger — The row that opens a submenu. Its chevron turns to point down once open.
  • Menu.SubContent — The rows a submenu reveals, opening in place rather than flying out sideways.

Examples

A menu of actions

Rows dismiss the panel as they run — that is what separates a menu of verbs from a picker of values. variant="destructive" tints the row as well as recolouring the label, because this is the one row where reading past it is expensive.

<Menu>
  <Menu.Trigger>
    <Button variant="outline">Options</Button>
  </Menu.Trigger>
  <Menu.Content align="start" width={224}>
    <Menu.Item icon={<ShareNodesIcon size={16} />} shortcut="⌘S">
      Share
    </Menu.Item>
    <Menu.Item icon={<PlusSquareIcon size={16} />}>Add to list</Menu.Item>
    <Menu.Item icon={<DownloadIcon size={16} />} disabled>
      Download
    </Menu.Item>
    <Menu.Separator />
    <Menu.Item variant="destructive" icon={<TrashIcon size={16} />}>
      Delete
    </Menu.Item>
  </Menu.Content>
</Menu>

Checkboxes and a radio group

Checkbox rows keep the menu open, because a set of filters is nearly always changed more than one at a time; radio rows close, because the question has been answered. indicator="dot" is quieter than a tick beside a list of nouns.

const [density, setDensity] = useState('comfortable');
const [showDone, setShowDone] = useState(true);

<Menu haptics>
  <Menu.Trigger>
    <Button variant="outline">View options</Button>
  </Menu.Trigger>
  <Menu.Content align="start" width={248}>
    <Menu.Label>Density</Menu.Label>
    <Menu.RadioGroup value={density} onValueChange={setDensity}>
      <Menu.RadioItem value="comfortable" indicator="dot">
        Comfortable
      </Menu.RadioItem>
      <Menu.RadioItem value="compact" indicator="dot">
        Compact
      </Menu.RadioItem>
    </Menu.RadioGroup>
    <Menu.Separator />
    <Menu.Label>Show</Menu.Label>
    <Menu.CheckboxItem checked={showDone} onCheckedChange={setShowDone}>
      Completed
    </Menu.CheckboxItem>
  </Menu.Content>
</Menu>

A submenu

Submenus expand in place. A flyout needs a pointer to travel from the parent row to the child panel without crossing anything that would close it, and a finger has no such path — so the rows open downwards into the panel, under the thumb that opened them.

<Menu>
  <Menu.Trigger>
    <Button variant="outline">File actions</Button>
  </Menu.Trigger>
  <Menu.Content align="start" width={244}>
    <Menu.Item icon={<PencilIcon size={16} />} shortcut="⌘R">
      Rename
    </Menu.Item>
    <Menu.Sub>
      <Menu.SubTrigger icon={<PackageIcon size={16} />}>Move to</Menu.SubTrigger>
      <Menu.SubContent>
        {['Inbox', 'Projects', 'Archive'].map((folder) => (
          <Menu.Item key={folder} onSelect={() => move(folder)}>
            {folder}
          </Menu.Item>
        ))}
      </Menu.SubContent>
    </Menu.Sub>
    <Menu.Separator />
    <Menu.Item
      variant="destructive"
      icon={<TrashIcon size={16} />}
      description="This cannot be undone"
    >
      Delete
    </Menu.Item>
  </Menu.Content>
</Menu>

Rows that line up without an icon

inset reserves the icon column on a row that has no icon, so its label starts where the others' labels do. It works on Menu.Label too, which is what keeps a heading over the same line as the rows beneath it.

<Menu>
  <Menu.Trigger>
    <Button variant="ghost" size="icon" accessibilityLabel="More">
      <EllipsisIcon size={18} />
    </Button>
  </Menu.Trigger>
  <Menu.Content align="end" width={232}>
    <Menu.Label inset>Account</Menu.Label>
    <Menu.Item inset>Profile</Menu.Item>
    <Menu.Item inset shortcut="⌘,">
      Settings
    </Menu.Item>
    <Menu.Separator />
    <Menu.Item icon={<LockIcon size={16} />}>Lock screen</Menu.Item>
  </Menu.Content>
</Menu>

As a bottom sheet

The same rows, moved into a draggable sheet — better on a small screen than a panel floating over the thing being acted on. Placement and width do not apply to a sheet.

<Menu presentation="bottom-sheet">
  <Menu.Trigger>
    <Button variant="outline">Open as a sheet</Button>
  </Menu.Trigger>
  <Menu.Content>
    <Menu.Item icon={<ShareNodesIcon size={16} />}>Share</Menu.Item>
    <Menu.Separator />
    <Menu.Item variant="destructive" icon={<TrashIcon size={16} />}>
      Delete
    </Menu.Item>
  </Menu.Content>
</Menu>

Variants

variant

  • default (default)
  • destructive
<Menu variant="default">…</Menu>
<Menu variant="destructive">…</Menu>

inset

  • true
<Menu inset="true">…</Menu>

API Reference

PropTypeDefaultDescription
hapticsbooleanTick the haptic engine as a row is chosen. Needs the optional expo-haptics, and is silent without it.
PropTypeDefaultDescription
scrollablebooleanScroll the rows when there are more of them than fit on screen. On by default, unlike the popover it is built on: a menu is a list, its length is usually a map over data rather than something written out by hand, and a row that cannot be reached is a row that may as well not exist.
PropTypeDefaultDescription
classNamestring
insetbooleanfalseLine the text up with rows that carry an icon or an indicator.
PropTypeDefaultDescription
classNamestring
PropTypeDefaultDescription
classNamestring
iconReactNodeLeading glyph, drawn in the indicator column.
descriptionstringSecond line under the label, for a row whose effect needs a sentence.
shortcutstringRight-aligned hint, for a row that also has a keyboard or gesture shortcut.
trailingReactNodeElement pinned to the row's trailing edge, after the shortcut. For the things a shortcut string cannot be — a chevron, a badge, a small avatar.
variantMenuItemVariant'default'destructive colours the row for an action that removes something.
insetbooleanfalseLine the label up with rows that carry an icon, without drawing one.
disabledbooleanfalse
closeOnSelectbooleantrueDismiss the menu once the row has run. Default true — a menu of verbs has done its job the moment one is chosen. Turn it off for a row that toggles something the user is likely to toggle twice.
onSelect() => void
PropTypeDefaultDescription
checkedbooleanfalse
onCheckedChange(checked: boolean) => void
PropTypeDefaultDescription
classNamestring
valuestring
onValueChange(value: string) => void
PropTypeDefaultDescription
valuestring
indicatorMenuRadioIndicator'check'check marks the chosen row, dot is quieter beside a list of nouns.
PropTypeDefaultDescription
defaultOpenboolean
openboolean
onOpenChange(open: boolean) => void
PropTypeDefaultDescription
transform[{ rotate: ${progress.value * 90 * sign}deg }],
PropTypeDefaultDescription
classNamestring

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

Every row carries the menuitem role, and a checkbox, radio or submenu row publishes its state alongside it — checked, selected or expanded — so a screen reader announces what the row is as well as what it says.

Menu.Content is capped to the room inside the safe area and scrolls, so the last row of a long menu is always reachable. Pass maxHeight to cap it lower than that, or scrollable={false} when the rows are written out by hand and known to fit.

On this page