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. A menu near the bottom of the screen opens upwards, and presentation="bottom-sheet" moves the same rows into a sheet.

To reach the same rows by holding the content itself, use ContextMenu.

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.Background />
    <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.Background — The panel's surface, drawn behind every row and outside its scroller. Rendered for you when you do not pass one; pass your own to put a gradient, an image or a blur under the rows.
  • 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>

Putting something behind the panel

The panel's surface is a layer rather than a background, so it can be replaced. Menu.Background fills the panel behind the rows and inside its corner radius — pass one with a tint, a gradient, an image or a blur view inside it and it stands in for the default. Tint with a theme token rather than a fixed colour — a hue picked to look right on one theme is the wrong hue on the next. Anything you put there is clipped to the panel and never scrolls with the rows.

<Menu>
  <Menu.Trigger>
    <Button variant="outline">Options</Button>
  </Menu.Trigger>
  <Menu.Content>
    {/* A tint over the overlay surface. Put a BlurView or a gradient in
        here instead and it fills the panel the same way. */}
    <Menu.Background className="bg-overlay">
      <View className="flex-1 bg-foreground/5" />
    </Menu.Background>
    <Menu.Item icon={<PencilIcon size={16} />}>Rename</Menu.Item>
    <Menu.Item icon={<ShareNodesIcon size={16} />}>Share</Menu.Item>
  </Menu.Content>
</Menu>

Variants

variant

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

inset

  • true
<Menu inset>…</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
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 }],
composethe glyph turns itself around under RTL, and the parent
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.

Indicators, and your own icons

The tick, the radio dot and the submenu chevron come from the library's own icon set, tinted from --color-popover-foreground and --color-muted-foreground. The chevron mirrors itself under RTL, and the quarter turn that opens a submenu is applied to the view around it — two transforms on two views, which compose, rather than one view trying to carry both.

Menu.Item’s icon prop takes any element, from any set, drawn in an 18pt column. Icons from outside this library do not read the ambient icon colour, so pass color yourself:

const tint = useCSSVariable('--color-popover-foreground');

<Menu.Item icon={<Pencil size={16} color={tint} />}>Rename</Menu.Item>

The panel's surface

The surface is a layer of its own — Menu.Background, an absolute fill behind the rows — rather than a background colour on the panel. A background cannot be got behind, and a menu that wants to be frosted or gradient-filled needs something under the rows and over nothing.

It draws on --color-overlay, which is a step further from the page than --color-popover. A menu is usually the thing furthest forward on the screen and often the thing on top of a popover; sharing one token with the panel underneath would make it disappear into it.

The layer is rendered outside the panel's scroller, so a surface stays put while the rows move over it. That is also why Menu.Background is lifted out of Menu.Content's children rather than simply rendered among them.

Press feedback

A pressed row fades its fill in over 90ms and back out over 160ms, and shrinks by 2% while it is held. active: could only swap the fill wholesale — in and out in a single frame each way, which on a row this size reads as a flash rather than as a press — and could not express the shrink at all. Both run on the UI thread off one shared value, and both are skipped when the system asks for reduced motion.

A blur belongs in the same slot: put an expo-blur BlurView inside Menu.Background and give the layer a semi-transparent tint (bg-overlay/70) so the blur shows through it.

Menu.Item composes consumer onPress, onPressIn, and onPressOut callbacks with its selection/dismissal and animated press feedback. The component retains ownership of its menu-item role, disabled state, row animation/style, and primary lifecycle after forwarding other Pressable props.

Public exports

Values: Menu

Types: MenuProps, MenuTriggerProps, MenuContentProps, MenuBackgroundProps, MenuLabelProps, MenuItemProps, MenuCheckboxItemProps, MenuRadioGroupProps, MenuRadioItemProps, MenuSeparatorProps, MenuSubProps, MenuSubTriggerProps, MenuSubContentProps, MenuItemVariant, MenuRadioIndicator

On this page