Aksara UI

Menu

A list of actions opened from a trigger.

import { Menu } from '@aksornproject/aksara-ui'

Basic menu

Menu is a namespace of parts with the Base UI Menu names and nesting. Use it to run actions. To pick a value, use Select.

Menu.Positioner places the panel below the trigger with the right edges aligned and an 8px gap. Menu.Popup is the 280px panel. Group items under a Menu.GroupLabel inside a Menu.Group, and divide them with Menu.Separator.

<Menu.Root>
  <Menu.Trigger>Actions</Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner>
      <Menu.Popup>
        <Menu.Group>
          <Menu.GroupLabel>Homework</Menu.GroupLabel>
          <Menu.Item>Edit</Menu.Item>
          <Menu.Item>Duplicate</Menu.Item>
          <Menu.Item endContent="⌘S">Share</Menu.Item>
        </Menu.Group>
        <Menu.Separator />
        <Menu.Item destructive>Delete</Menu.Item>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

Triggers

Menu.Trigger draws a labelled trigger with a chevron. size is default (44px tall) or small (36px). The label truncates at 240px. The chevron points down while closed and up while open. chevron={false} hides it.

iconOnly makes a square trigger with no label or chevron. It shows the dots icon unless a leadingIcon or children replace it. Give it an aria-label. disabled stops the trigger opening the menu.

<ActionsMenu>Actions</ActionsMenu>
<ActionsMenu size="small">Actions</ActionsMenu>
<ActionsMenu iconOnly aria-label="More actions" />
<ActionsMenu size="small" iconOnly aria-label="More actions" />
<ActionsMenu leadingIcon={<IconInterfaceEssentialSetting />}>
  Settings
</ActionsMenu>
<ActionsMenu chevron={false}>No chevron</ActionsMenu>
<ActionsMenu disabled>Disabled</ActionsMenu>

Items

Children are the label. It wraps and the row grows. description adds a second line. leadingIcon is a 16px slot. endContent holds a shortcut or similar and truncates at 60px. destructive is for delete actions only.

A disabled item stays focusable so keyboard users can find it, but it does not run. Menu.LinkItem renders the same row as an <a>.

leadingIcon and endContent take an element, a function or anything else. A single element becomes the slot and gets the slot classes and the state as data-* attributes. It must forward its props to the DOM. A function (props, state) => element gets the slot props to spread and the item state, { destructive, disabled }. Anything else renders inside a slot span. The trigger leadingIcon works the same way with the trigger state, { open, disabled, size, iconOnly }.

<Menu.Root>
  <Menu.Trigger>Assignment</Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner>
      <Menu.Popup>
        <Menu.Item leadingIcon={<IconInterfaceEssentialCopy />}>
          Duplicate
        </Menu.Item>
        <Menu.Item
          leadingIcon={<IconInterfaceEssentialShare />}
          description="Anyone with the link can view"
        >
          Share
        </Menu.Item>
        <Menu.Item endContent="⌘E">Edit</Menu.Item>
        <Menu.Item disabled>Archive</Menu.Item>
        <Menu.LinkItem
          href="/docs/components/dialog"
          leadingIcon={<IconInterfaceEssentialAttachmentLink />}
        >
          Open the Dialog page
        </Menu.LinkItem>
        <Menu.Separator />
        <Menu.Item
          destructive
          leadingIcon={<IconInterfaceEssentialTrashDeleteBin2 />}
        >
          Delete
        </Menu.Item>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

Checkbox and radio items

Menu.CheckboxItem draws the checkbox slot. Menu.RadioItem goes inside a Menu.RadioGroup and shows a tick when checked. Checked rows of both get the selection fill.

The radio tick sits at the end. indicator="start" moves it to the start. Pass closeOnClick to close the menu after a pick.

<Menu.Root>
  <Menu.Trigger>View</Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner>
      <Menu.Popup>
        <Menu.CheckboxItem checked={grid} onCheckedChange={setGrid}>
          Show grid
        </Menu.CheckboxItem>
        <Menu.Separator />
        <Menu.RadioGroup value={sort} onValueChange={setSort}>
          <Menu.GroupLabel>Sort by</Menu.GroupLabel>
          <Menu.RadioItem value="newest" closeOnClick>
            Newest
          </Menu.RadioItem>
          <Menu.RadioItem value="oldest" closeOnClick>
            Oldest
          </Menu.RadioItem>
          <Menu.RadioItem value="name" indicator="start" closeOnClick>
            Name
          </Menu.RadioItem>
        </Menu.RadioGroup>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

Menu.SubmenuRoot wraps a Menu.SubmenuTrigger and the Menu.Portal it opens. The trigger row shows a chevron. The nested Menu.Positioner opens the panel beside the row with the first rows lined up.

<Menu.Root>
  <Menu.Trigger>Lesson</Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner align="start">
      <Menu.Popup>
        <Menu.Item>Edit</Menu.Item>
        <Menu.SubmenuRoot>
          <Menu.SubmenuTrigger>Move to</Menu.SubmenuTrigger>
          <Menu.Portal>
            <Menu.Positioner>
              <Menu.Popup>
                <Menu.Item>Unit 1</Menu.Item>
                <Menu.Item>Unit 2</Menu.Item>
                <Menu.Item>Archive</Menu.Item>
              </Menu.Popup>
            </Menu.Positioner>
          </Menu.Portal>
        </Menu.SubmenuRoot>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

Wrap the menu in Menu.FilterProvider and put Menu.Search in the popup. Without the provider, Base UI throws an error that says so. Base UI hides the items that do not match. Pass filter={null} to the provider to filter the items yourself.

The search row stays above the list while the list scrolls. Put Menu.Search directly in Menu.Popup or inside a fragment. The arrow keys move the highlight while the field keeps focus. The clear button shows while the field has text.

<Menu.FilterProvider>
  <Menu.Root>
    <Menu.Trigger>Assign to class</Menu.Trigger>
    <Menu.Portal>
      <Menu.Positioner>
        <Menu.Popup>
          <Menu.Search />
          {CLASSES.map((name) => (
            <Menu.Item key={name}>{name}</Menu.Item>
          ))}
        </Menu.Popup>
      </Menu.Positioner>
    </Menu.Portal>
  </Menu.Root>
</Menu.FilterProvider>

Scrolling

maxHeight on Menu.Popup caps the item list, in px or any CSS length. The list scrolls natively past it with an 8px gutter. Without it, the panel hugs its items up to the space the viewport has.

<Menu.Root>
  <Menu.Trigger>Jump to week</Menu.Trigger>
  <Menu.Portal>
    <Menu.Positioner>
      <Menu.Popup maxHeight={240}>
        {WEEKS.map((week) => (
          <Menu.Item key={week}>{week}</Menu.Item>
        ))}
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

Custom trigger

render replaces the built-in trigger with your element, such as a library Button. The trigger then adds only the behaviour and ARIA, with no classes or content. render cannot be combined with size, iconOnly, leadingIcon or chevron.

Here the Button keeps its focus ring while the menu is open through forceFocusVisible.

<Menu.Root open={open} onOpenChange={setOpen}>
  <Menu.Trigger
    render={(props) => (
      <Button
        {...props}
        iconOnly
        color="brand"
        variant="tertiary-accent"
        size="sm"
        aria-label="Grade actions"
        forceFocusVisible={open}
      >
        <IconInterfaceEssentialDots />
      </Button>
    )}
  />
  <Menu.Portal>
    <Menu.Positioner>
      <Menu.Popup>
        <Menu.Item
          destructive
          leadingIcon={<IconInterfaceEssentialTrashDeleteBin2 />}
        >
          Delete grade
        </Menu.Item>
      </Menu.Popup>
    </Menu.Positioner>
  </Menu.Portal>
</Menu.Root>

Accessibility

  • The trigger is a native button with aria-haspopup="menu" and aria-expanded. Give an icon-only trigger an aria-label.
  • Items are menuitem, menuitemcheckbox or menuitemradio.
  • Arrow keys, Home, End and typeahead move between items. Enter and Space run them, and Escape closes.
  • Menu.Search is a searchbox named "Search". It sits outside the menu role. Its clear button stays out of the tab order and the accessibility tree.
  • The menu is modal by default. Pass modal={false} to Menu.Root to keep the page scrollable and clickable while it is open.

API

Prop

Type

Prop

Type

Prop

Type

side defaults to bottom, align to end and sideOffset to 8. Inside a Menu.SubmenuRoot, side defaults to inline-end, align to start and alignOffset to -4.

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

Prop

Type

placeholder defaults to "Search..." and aria-label to "Search". The ref and className reach the input.

Prop

Type

Prop

Type

Edit on GitHub

On this page