A popover list of actions and choices, with key hints, sections and router links.

Basic

A list of actions that opens from a button, in a Popover.

  • Items: each has a label, and can have an icon, a second line, a preformatted value at its end and a key hint. Mod in a hint is Cmd on Apple platforms and Ctrl elsewhere.
  • Sections: group items under a heading. A separator rules between groups.
  • Danger: variant="danger" shows the label and icon in the error color, for an item that loses something.

Rows are 40px tall, and 44px on a touch screen.

Example

Nothing chosen yet.

Follows the page theme and corners
'use client';

import {
  IconButton,
  Menu,
  MenuItem,
  MenuSection,
  MenuSeparator,
  MenuTrigger,
  Text,
} from '@snapshot-ui/react';
import { Copy, Download, Ellipsis, Pencil, Share2, Trash2 } from 'lucide-react';
import { useState, type ReactElement } from 'react';

const LABELS: Record<string, string> = {
  rename: 'Rename',
  duplicate: 'Duplicate',
  share: 'Share',
  delete: 'Delete the project',
};

export default function MenuBasic(): ReactElement {
  const [chosen, setChosen] = useState<string | null>(null);
  return (
    <div style={{ display: 'grid', gap: 16, justifyItems: 'center' }}>
      <MenuTrigger>
        <IconButton aria-label="Project actions" variant="outline">
          <Ellipsis />
        </IconButton>
        <Menu
          onAction={(key) => {
            setChosen(LABELS[String(key)] ?? null);
          }}
        >
          <MenuSection title="Project">
            <MenuItem id="rename" icon={<Pencil />}>
              Rename
            </MenuItem>
            <MenuItem id="duplicate" icon={<Copy />} shortcut="Mod D">
              Duplicate
            </MenuItem>
            <MenuItem id="share" icon={<Share2 />} description="Anyone with the link can view">
              Share
            </MenuItem>
            <MenuItem
              id="export"
              icon={<Download />}
              description="On the Team plan"
              shortcut="Mod E"
              isDisabled
            >
              Export
            </MenuItem>
          </MenuSection>
          <MenuSeparator />
          <MenuItem id="delete" icon={<Trash2 />} variant="danger">
            Delete the project
          </MenuItem>
        </Menu>
      </MenuTrigger>
      <Text tone="muted">{chosen === null ? 'Nothing chosen yet.' : `Chose ${chosen}.`}</Text>
    </div>
  );
}

Selection

A menu can also choose. Set selectionMode on the menu, or on a section to make it choose apart from the rest, as the theme and panels do here.

  • selectionMode="single": items are radios. The chosen row is the ink pill with a check, and a choice closes the menu.
  • selectionMode="multiple": items are checkboxes. A chosen row shows only the check, and the menu stays open.
  • shouldCloseOnSelect overrides closing on the menu, a section or one item.

Every row in a selectable list reserves room for the check, so the width never changes.

Example

Follows the page theme and corners
import { Button, Menu, MenuItem, MenuSection, MenuSeparator, MenuTrigger } from '@snapshot-ui/react';
import { Monitor, Moon, Sun } from 'lucide-react';
import type { ReactElement } from 'react';

export default function MenuSelection(): ReactElement {
  return (
    <MenuTrigger>
      <Button variant="secondary">View options</Button>
      <Menu>
        <MenuSection title="Theme" selectionMode="single" defaultSelectedKeys={['system']}>
          <MenuItem id="light" icon={<Sun />}>
            Light
          </MenuItem>
          <MenuItem id="dark" icon={<Moon />}>
            Dark
          </MenuItem>
          <MenuItem id="system" icon={<Monitor />} description="Follows the device">
            System
          </MenuItem>
        </MenuSection>
        <MenuSeparator />
        <MenuSection title="Show" selectionMode="multiple" defaultSelectedKeys={['sidebar']}>
          <MenuItem id="sidebar">Sidebar</MenuItem>
          <MenuItem id="minimap">Minimap</MenuItem>
        </MenuSection>
      </Menu>
    </MenuTrigger>
  );
}

An item with href is a real link, an anchor with role="menuitem", so a middle or modified click opens it as the browser would. A plain press navigates through React Aria's RouterProvider, as Link does. One provider at the app's root routes every link, set up in Routing.

To render your router's own link, use the item's inherited render prop in a client component. An item without href renders a div:

<MenuItem
  id="settings"
  href="/settings"
  render={(props) => ('href' in props ? <NextLink {...props} href={props.href} /> : <div {...props} />)}
>
  Settings
</MenuItem>

Example

Follows the page theme and corners
'use client';

import { Button, Menu, MenuItem, MenuTrigger, RouterProvider } from '@snapshot-ui/react';
import { useRouter } from 'next/navigation';
import type { ReactElement } from 'react';

const PAGES = [
  { slug: 'button', title: 'Button' },
  { slug: 'dialog', title: 'Dialog' },
  { slug: 'popover', title: 'Popover' },
];

export default function MenuLinks(): ReactElement {
  const router = useRouter();
  // An app wraps its whole tree once; the example wraps only itself.
  return (
    <RouterProvider
      navigate={(href) => {
        router.push(href);
      }}
    >
      <MenuTrigger>
        <Button variant="secondary">Jump to a component</Button>
        <Menu>
          {PAGES.map(({ slug, title }) => (
            <MenuItem key={slug} id={slug} href={`/docs/components/${slug}`}>
              {title}
            </MenuItem>
          ))}
        </Menu>
      </MenuTrigger>
    </RouterProvider>
  );
}

Put a MenuItem and then a Menu in a SubmenuTrigger for an item that opens a submenu. The item draws a chevron after its end value, in the key hint's place, and keeps its wash while the submenu is open, so you can see where it came from. The submenu opens beside the row at its end, its first row level with the row, and on the other side where there is no room; where neither side has room, as on a narrow phone, it opens under the row, over the rows below it.

Arrow Right opens it, or a press, or a pause of the pointer over the row, 200ms unless SubmenuTrigger's delay says otherwise; Arrow Left or Escape closes that level alone, with focus back on the row, and the menu takes the Escape, so a dialog around it stays open until the next one; a choice in it closes every level and gives focus back to the menu's trigger. React Aria takes the open and close arrows from the locale, so a right-to-left page sets I18nProvider with its locale and the two arrows swap. For the reader's theme, ThemeMenu is a submenu ready made.

Example

In Inbox

Follows the page theme and corners
'use client';

import {
  IconButton,
  Menu,
  MenuItem,
  MenuSeparator,
  MenuTrigger,
  SubmenuTrigger,
  Text,
  type Key,
} from '@snapshot-ui/react';
import { Copy, Ellipsis, FolderInput, Trash2 } from 'lucide-react';
import { useState, type ReactElement } from 'react';

const FOLDERS: Record<string, string> = { inbox: 'Inbox', receipts: 'Receipts', taxes: 'Taxes' };

/** A Move to item opening a submenu of folders: Arrow Right opens it, Arrow Left or Escape closes it. */
export default function MenuSubmenu(): ReactElement {
  const [folder, setFolder] = useState<Key>('inbox');
  return (
    <div style={{ display: 'grid', gap: 16, justifyItems: 'center' }}>
      <MenuTrigger>
        <IconButton aria-label="Document actions" variant="outline">
          <Ellipsis />
        </IconButton>
        <Menu>
          <MenuItem id="duplicate" icon={<Copy />}>
            Duplicate
          </MenuItem>
          <SubmenuTrigger>
            <MenuItem id="move" icon={<FolderInput />} end={FOLDERS[String(folder)]}>
              Move to
            </MenuItem>
            <Menu
              selectionMode="single"
              selectedKeys={[folder]}
              disallowEmptySelection
              onSelectionChange={(keys) => {
                const [next] = keys === 'all' ? [] : [...keys];
                if (next !== undefined) setFolder(next);
              }}
            >
              {Object.entries(FOLDERS).map(([id, name]) => (
                <MenuItem key={id} id={id}>
                  {name}
                </MenuItem>
              ))}
            </Menu>
          </SubmenuTrigger>
          <MenuSeparator />
          <MenuItem id="delete" icon={<Trash2 />} variant="danger">
            Delete
          </MenuItem>
        </Menu>
      </MenuTrigger>
      <Text>In {FOLDERS[String(folder)]}</Text>
    </div>
  );
}

Usage

Put a button and a Menu in a MenuTrigger. To control it, pass isOpen, defaultOpen and onOpenChange to MenuTrigger. Menu is a client component, and a server component can render it, as the selection example does with defaultSelectedKeys. A menu that runs a handler or keeps its choice in state needs a client component, as the other two examples do.

  • onAction on the menu receives the id of the item that ran. An item's own onAction runs first, with no argument.
  • A danger item usually asks first. Pair it with a Confirm dialog whose confirm button names the same outcome.
  • The popover opens under the button at its start edge, and flips where the screen has no room. placement changes that.
  • The popover is at least 12rem wide, or the button's width if wider. A long menu scrolls inside it.
  • A menu opened from a dark card is dark on a light page, as Theming explains.

Props

ref and className go on the menu element, and popoverClassName on the popover around it. The rest of React Aria's menu props, such as items with a render function and renderEmptyState, pass through.

className
ClassNameOrFunction<MenuRenderProps>

Goes on the menu element: a string, or a function of the menu's state (isEmpty, defaultClassName).

defaultSelectedKeys
"all" | Iterable<Key>

The initial selected keys in the collection (uncontrolled).

disabledKeys
Iterable<Key>

The item keys that are disabled. These items cannot be selected, focused, or otherwise interacted with.

onAction
((key: Key, value: object) => void)

Handler that is called when an item is selected.

onSelectionChange
((keys: Selection) => void)

Handler that is called when the selection changes.

placement
Placement

Where the popover opens.

Default: 'bottom start', and 'end top' for a submenu

popoverClassName
string

Goes on the popover around it.

selectedKeys
"all" | Iterable<Key>

The currently selected keys in the collection (controlled).

selectionMode
SelectionMode

The type of selection that is allowed in the collection.

shouldCloseOnSelect
boolean

Whether the menu should close when the menu item is selected.

children
ReactNode

The label. A string is also the item's text value, for typeahead and announcement.

className
string | ((values: MenuItemRenderProps & { defaultClassName: string | undefined; }) => string)

description
ReactNode

A second line under the label, in slate. It becomes the item's accessible description.

end
ReactNode

A preformatted value at the end, such as an amount, before the key hint. Shown as given. It describes the item and is not part of its name.

href
string

A URL to link to. See [MDN](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a#href).

icon
ReactNode

A 16px icon at the start, hidden from assistive technology.

id
Key

The unique id of the item.

isDisabled
boolean

Whether the item is disabled.

onAction
(() => void)

Handler that is called when the item is selected.

shortcut
string

A key hint at the end, such as "Q" or "Mod ,", drawn as keys. Separate keys with spaces. "Mod" shows as Cmd or Ctrl once mounted.

shouldCloseOnSelect
boolean

Whether the menu should close when the menu item is selected.

textValue
string

A string representation of the item's contents, used for features like typeahead.

variant
"danger" | "default"

danger draws the label and icon in the error color, for an item that loses something.

Default: 'default'

className
string

defaultSelectedKeys
"all" | Iterable<Key>

The initial selected keys in the collection (uncontrolled).

onSelectionChange
((keys: Selection) => void)

Handler that is called when the selection changes.

selectedKeys
"all" | Iterable<Key>

The currently selected keys in the collection (controlled).

selectionMode
SelectionMode

The type of selection that is allowed in the collection.

shouldCloseOnSelect
boolean

Whether the menu should close when the menu item is selected.

title
ReactNode

The heading above the section's items, in the overline style.

className
string

ref
Ref<HTMLElement>

Accessibility

A true role="menu", named by its trigger, which says that it opens a menu and whether it is open. Items are menu items, or radios and checkboxes in single and multiple selection. Each is named by its label and described by its second line and key hint.

  • A disabled item is announced as unavailable, and the arrows skip it.
  • Opened from the keyboard, focus starts on the first item, or the last with Arrow Up. Opened with a pointer, it starts on the list, or on the chosen item when the menu selects.
  • Focus stays in the open menu until a choice, Escape or a press outside closes it, then returns to the trigger.
DownUpMove between items, stopping at the first and the last.
HomeEndJump to the first or last item.
A-ZJump to an item by its first letters.
EnterSpaceActivate the item, or choose it.
RightOn an item with a submenu, open it with focus on its first item, or its chosen one. Left in right to left.
LeftIn a submenu, close it and return focus to its item. Right in right to left.
EscapeCloses the menu, or a submenu alone, and returns focus.
TabStays in the open menu.

Design rules

What keeps menus in the system.

Say the action in a verb, such as Rename or Duplicate.

Group related items in a section with a heading.

Put a danger item last, after a separator.

Never nest a menu in a menu.

Never use a menu for navigation that a list on the page could show.

Never mark danger by color alone. Name what is lost, such as Delete the project.

esc

↑ ↓ to move↵ to open