Skip to content

Selection

Menus

A list of choices on a temporary surface: standard and vibrant colors, sections, and selectable items.

Examples

Menu with groups

Icons, trailing shortcuts, supporting text, a disabled item and a labelled group with a divider. The arrow keys move through the items and typing jumps to one; Escape closes the menu and returns focus to its button.

The live demo runs with JavaScript. Its code is below.

React HTML
import { Button, Menu, MenuItem, MenuSection, MenuTrigger } from '@kyralabs/m3exp-web/react';

export default function MenuExample() {
  return (
    <MenuTrigger>
      <Button variant="tonal">Edit</Button>
      <Menu
        aria-label="Edit"
        onAction={(key) => {
          console.log(key);
        }}
      >
        <MenuSection id="clipboard" aria-label="Clipboard">
          <MenuItem id="cut" icon="content_cut" trailingText="Ctrl+X">
            Cut
          </MenuItem>
          <MenuItem id="copy" icon="content_copy" trailingText="Ctrl+C">
            Copy
          </MenuItem>
          <MenuItem id="paste" icon="content_paste" trailingText="Ctrl+V" isDisabled>
            Paste
          </MenuItem>
        </MenuSection>
        <MenuSection id="more" title="More" divider>
          <MenuItem id="rename" icon="edit" supportingText="Change the file name">
            Rename
          </MenuItem>
          <MenuItem id="delete" icon="delete">
            Delete
          </MenuItem>
        </MenuSection>
      </Menu>
    </MenuTrigger>
  );
}
<button type="button" id="edit-button" class="m3e-button m3e-button--tonal">
  <span class="m3e-button__label">Edit</span>
</button>
<m3e-menu anchor="edit-button" label="Edit">
  <m3e-menu-group aria-label="Clipboard">
    <m3e-menu-item value="cut" icon="content_cut" trailing-text="Ctrl+X">Cut</m3e-menu-item>
    <m3e-menu-item value="copy" icon="content_copy" trailing-text="Ctrl+C">Copy</m3e-menu-item>
    <m3e-menu-item value="paste" icon="content_paste" trailing-text="Ctrl+V" disabled
      >Paste</m3e-menu-item
    >
  </m3e-menu-group>
  <m3e-menu-group label="More" divider>
    <m3e-menu-item value="rename" icon="edit" supporting-text="Change the file name"
      >Rename</m3e-menu-item
    >
    <m3e-menu-item value="delete" icon="delete">Delete</m3e-menu-item>
  </m3e-menu-group>
</m3e-menu>
<script>
  document.querySelector('m3e-menu[anchor="edit-button"]').addEventListener('action', (event) => {
    console.log(event.detail.value);
  });
</script>

Run the HTML version (custom elements, no framework).

Single selection, vibrant

A vibrant menu whose items are radio items: the selected one shows a check.

The live demo runs with JavaScript. Its code is below.

React HTML
import { IconButton, Icon, Menu, MenuItem, MenuTrigger } from '@kyralabs/m3exp-web/react';

export default function Selection() {
  return (
    <MenuTrigger>
      <IconButton aria-label="Sort">
        <Icon name="sort" />
      </IconButton>
      <Menu
        aria-label="Sort by"
        variant="vibrant"
        selectionMode="single"
        defaultSelectedKeys={['date']}
      >
        <MenuItem id="name" icon="sort_by_alpha">
          Name
        </MenuItem>
        <MenuItem id="date" icon="schedule">
          Date modified
        </MenuItem>
        <MenuItem id="size" icon="straighten">
          Size
        </MenuItem>
      </Menu>
    </MenuTrigger>
  );
}
<button
  type="button"
  id="sort-button"
  class="m3e-icon-button m3e-icon-button--standard"
  aria-label="Sort"
>
  <span class="m3e-icon" aria-hidden="true">sort</span>
</button>
<m3e-menu anchor="sort-button" label="Sort by" variant="vibrant">
  <m3e-menu-item type="radio" value="name" icon="sort_by_alpha">Name</m3e-menu-item>
  <m3e-menu-item type="radio" value="date" icon="schedule" checked>Date modified</m3e-menu-item>
  <m3e-menu-item type="radio" value="size" icon="straighten">Size</m3e-menu-item>
</m3e-menu>

Run the HTML version (custom elements, no framework).

API

React

<Menu>

Menu (COMPONENT_SPEC §21): the expressive vertical menu (standard or vibrant colors; sections become separate groups, "segmented") or Compose's baseline DropdownMenu. Inside a <MenuTrigger> (or as a <MenuItem submenu>) it is a popover placed by Compose's DropdownMenuPositionProvider and opened/closed with interruptible springs (m3e-elements MenuPopup); on its own it renders inline. React Aria useMenu provides the APG keyboard model and typeahead; selectionMode makes items checkable (multiple) or selectable (single).

Prop Type Default Description
children required ReactNode <MenuItem> and <MenuSection> elements.
variant "standard" | "vibrant" | "baseline" standard Color and layout. Default standard.
selectionMode "none" | "single" | "multiple" single: items are radios (menuitemradio); multiple: checkboxes (menuitemcheckbox).
disallowEmptySelection boolean Keep at least one item selected.
position "start" | "end" | "below" | "above" | "left" | "right" Overrides the trigger's placement for this menu.
offset { x: number; y: number; } DpOffset in px (x mirrored in RTL).
selectedKeys 'all' | Iterable<Key>
defaultSelectedKeys 'all' | Iterable<Key>
disabledKeys Iterable<Key>
aria-label string
aria-labelledby string
className string
id string
onAction (key: Key) => void Called with the item's key when an item is activated.
onSelectionChange (keys: Selection) => void

<MenuItem>

A menu item: an action, or checkable/selectable when the menu has a selectionMode.

Prop Type Default Description
id Key Unique within the menu (React Aria key). Defaults to the element's React key.
children required ReactNode The label.
textValue string Typeahead text when children is not a plain string.
icon string | ReactElement Leading icon: a Material Symbols name or an <Icon>.
checkedIcon string | ReactElement Leading icon while checked (selectable menus). Without icon, the slot expands in when the item is checked and collapses when it is not (Compose's AnimatedVisibility).
supportingText ReactNode Second line (bodyMedium).
trailingIcon string | ReactElement Trailing icon: a Material Symbols name or an <Icon>.
trailingText ReactNode Trailing text, e.g. a keyboard shortcut (announced as the item's description).
submenu ReactElement A nested <Menu>: the item opens it (hover, press, ArrowRight / ArrowLeft in RTL).
aria-label string
isDisabled boolean
onAction () => void Called when this item is activated (in addition to the menu's onAction).

<MenuSection>

A group of items (Compose DropdownMenuGroup): its own surface, separated by a gap.

Prop Type Default Description
title ReactNode Group label (MenuDefaults.DropdownMenuGroupLabel). Without it, pass aria-label.
divider boolean Draws a divider under the label (MenuDefaults.HorizontalDividerPadding).
id Key
aria-label string
children required ReactNode

<MenuTrigger>

Opens a <Menu> from a button (COMPONENT_SPEC §21; APG menu button). A press opens it; from the keyboard, Enter, Space and ArrowDown open it on the first item and ArrowUp on the last. The trigger gets aria-haspopup, aria-expanded and, while open, aria-controls. Opening follows Compose's IconButton onClick (on release), not React Aria's press start.

Prop Type Default Description
children required [ReactElement, ReactElement] | ReactNode Two children: the trigger (a component that takes onPress, onKeyDown, ARIA props and a ref, such as <Button> or <IconButton>) and the <Menu>.
position "start" | "end" | "below" | "above" | "left" | "right" below Where the menu opens. Default below (flips above when there is no room).
isOpen boolean
defaultOpen boolean
isDisabled boolean
onOpenChange (isOpen: boolean) => void

Custom elements

<m3e-menu>

import '@kyralabs/m3e-elements/menu.js';

<m3e-menu anchor="more-button" variant="vibrant" label="Edit"> (COMPONENT_SPEC §21). Renders the m3e-css .m3e-menu contract inside itself (the host is display: contents) from its <m3e-menu-item> / <m3e-menu-group> children, as a popover="manual" placed and animated by MenuPopup (Compose DropdownMenuPositionProvider + open/close springs).

  • anchor: id of the trigger. It gets aria-haspopup, aria-expanded and aria-controls; a click toggles the menu, Enter/Space/ArrowDown open it on the first item, ArrowUp on the last. anchorElement sets the trigger directly.
  • open (reflected), variant (standard | vibrant | baseline), position (below | above | start | end | left | right), label (the menu's accessible name; default: labelled by the anchor).
  • Keyboard (APG menu, as React Aria's useMenu): ArrowUp/Down (wrapping), Home/End, typeahead, Enter/Space activate, Escape closes and returns focus to the trigger, Tab closes and moves on, ArrowRight (ArrowLeft in RTL) opens a submenu and ArrowLeft closes it.
  • Events: action on the <m3e-menu-item> (bubbles) with detail: { value, checked }; open-change on the menu with detail: { open }.
Attribute Property Type Notes
open open boolean Reflected.
anchor anchor string
variant variant string
position position string
label label string
Event Detail Description
open-change OpenChangeDetail Opened or closed (after the change).
action MenuActionDetail An item was activated (from the <m3e-menu-item>, bubbling).

<m3e-menu-item>

import '@kyralabs/m3e-elements/menu.js';

<m3e-menu-item value="edit" icon="edit" supporting-text="…" trailing-text="⌘E">Edit</m3e-menu-item> — data for one item of a <m3e-menu> (it renders nothing itself; the menu renders .m3e-menu__item). type="checkbox" / type="radio" make it checkable / selectable (checked); checked-icon replaces icon while checked (alone, it expands in); submenu="<id>" opens another <m3e-menu>. The label is the text content.

Attribute Property Type Notes
value value string
type type string
checked checked boolean Reflected.
disabled disabled boolean
icon icon string
checked-icon checkedIcon string
trailing-icon trailingIcon string
trailing-text trailingText string
supporting-text supportingText string
submenu submenu string
Event Detail Description
action MenuActionDetail Activated (it bubbles to the menu).

<m3e-menu-group>

import '@kyralabs/m3e-elements/menu.js';

<m3e-menu-group label="Navigation" divider> — a group of <m3e-menu-item>s (Compose DropdownMenuGroup): its own surface, separated from the next group by a gap. Without label, give it an aria-label. divider draws a divider under the label.

Attribute Property Type Notes
label label string
divider divider boolean
aria-label groupLabel string

CSS

menu.css

@import '@kyralabs/m3e-css/menu.css';

Menus (COMPONENT_SPEC §21): the expressive vertical menu (standard and vibrant colors; groups separated by gaps = "segmented"), checkable and selectable items, and the baseline DropdownMenu. CSS draws every state; m3e-elements (<m3e-menu>) and m3e-react (<Menu>) add positioning, focus management, typeahead and the interruptible open/close springs.

<div class="m3e-menu" role="menu" aria-label="Edit" popover="manual">
  <div class="m3e-menu__group" role="group" aria-labelledby="g1">
    <div class="m3e-menu__label" id="g1">Modification</div>
    <div class="m3e-menu__divider" role="separator"></div>
    <div class="m3e-menu__item" role="menuitem" tabindex="-1">
      <span class="m3e-menu__item-leading"><span class="m3e-icon" aria-hidden="true">edit</span></span>
      <span class="m3e-menu__item-text">
        <span class="m3e-menu__item-label">Edit</span>
        <span class="m3e-menu__item-supporting">Edit mode</span>
      </span>
      <span class="m3e-menu__item-trailing">⌘E</span>
    </div>
    <div class="m3e-menu__item" role="menuitemcheckbox" aria-checked="true" tabindex="-1">…</div>
  </div>
  <div class="m3e-menu__group" role="group" aria-label="More">…</div>
</div>
  • Root: a menu whose items are direct children is one standalone group (the root is the
surface); a menu of .m3e-menu__group children is segmented (transparent root, groups separated
by SegmentedMenuTokens.SegmentedGap). Without [popover] the menu renders inline (static).
  • Modifiers: --vibrant (VibrantMenuTokens), --baseline (Compose DropdownMenu + the
non-expressive DropdownMenuItem: MenuTokens surface, 48dp items, no item shapes).
  • Items: [aria-checked] makes an item selectable (menuitemcheckbox / menuitemradio), with the
selectable colors and the selected shape when "true". [aria-disabled='true'] disables it.
A leading slot marked --toggle holds only a checked icon: it collapses while unchecked.
  • JS hooks: [data-driven] (open/close springs write --_m3e-menu-scale / -opacity),
[data-positioned] (DropdownMenuPositionProvider writes --_m3e-menu-x / -y and the
transform origin --_m3e-menu-origin-x / -y), [data-hovered] on a group once the pointer has
entered it (it then takes the inactive shape when the pointer leaves).
69 custom properties it reads
Custom property Default
--m3e-comp-divider-color var(--m3e-sys-color-outline-variant)
--m3e-comp-divider-thickness 1px
--m3e-comp-list-item-label-text-color var(--m3e-sys-color-on-surface)
--m3e-comp-list-item-label-text-color-disabled var(--m3e-sys-color-on-surface)
--m3e-comp-list-item-label-text-opacity-disabled 0.38
--m3e-comp-list-item-leading-icon-color var(--m3e-sys-color-on-surface-variant)
--m3e-comp-list-item-leading-icon-color-disabled var(--m3e-sys-color-on-surface)
--m3e-comp-list-item-leading-icon-opacity-disabled 0.38
--m3e-comp-list-item-leading-icon-size 24px
--m3e-comp-list-item-trailing-icon-color var(--m3e-sys-color-on-surface-variant)
--m3e-comp-list-item-trailing-icon-color-disabled var(--m3e-sys-color-on-surface)
--m3e-comp-list-item-trailing-icon-opacity-disabled 0.38
--m3e-comp-list-item-trailing-icon-size 24px
--m3e-comp-menu-baseline-horizontal-margin 0
--m3e-comp-menu-baseline-item-height 48px
--m3e-comp-menu-baseline-vertical-padding 8px
--m3e-comp-menu-closed-opacity 0
--m3e-comp-menu-closed-scale 0.8
--m3e-comp-menu-container-color var(--m3e-sys-color-surface-container)
--m3e-comp-menu-container-shadow var(--m3e-sys-elevation-level2-shadow)
--m3e-comp-menu-container-shape var(--m3e-sys-shape-corner-extra-small)
--m3e-comp-menu-divider-padding-horizontal 12px
--m3e-comp-menu-divider-padding-vertical 2px
--m3e-comp-menu-group-label-min-height 32px
--m3e-comp-menu-group-label-padding-end 4px
--m3e-comp-menu-group-label-padding-start 12px
--m3e-comp-menu-group-vertical-padding 2px
--m3e-comp-menu-horizontal-margin 8px
--m3e-comp-menu-item-horizontal-padding 12px
--m3e-comp-menu-item-icon-label-space 8px
--m3e-comp-menu-item-max-width 280px
--m3e-comp-menu-item-min-width 112px
--m3e-comp-menu-item-outer-padding-horizontal 4px
--m3e-comp-menu-item-outer-padding-vertical-supporting 2px
--m3e-comp-menu-item-vertical-padding 12px
--m3e-comp-menu-vertical-margin 48px
--m3e-comp-minimum-interactive-component-size 48px
--m3e-comp-segmented-menu-container-shape var(--m3e-sys-shape-corner-large)
--m3e-comp-segmented-menu-group-shape var(--m3e-sys-shape-corner-small)
--m3e-comp-segmented-menu-inactive-container-shape var(--m3e-sys-shape-corner-small)
--m3e-comp-segmented-menu-item 44px
--m3e-comp-segmented-menu-item-first-child-shape var(--m3e-sys-shape-corner-medium)
--m3e-comp-segmented-menu-item-last-child-inner-corner-corner-size var(--m3e-sys-shape-corner-extra-small)
--m3e-comp-segmented-menu-item-last-child-shape var(--m3e-sys-shape-corner-medium)
--m3e-comp-segmented-menu-item-leading-icon-size 20px
--m3e-comp-segmented-menu-item-shape var(--m3e-sys-shape-corner-extra-small)
--m3e-comp-segmented-menu-item-shape-selected var(--m3e-sys-shape-corner-medium)
--m3e-comp-segmented-menu-item-trailing-icon-size 20px
--m3e-comp-segmented-menu-segmented-gap 2px
--m3e-comp-standard-menu-container-color var(--m3e-sys-color-surface-container-low)
--m3e-comp-standard-menu-item-container-color-selected var(--m3e-sys-color-tertiary-container)
--m3e-comp-standard-menu-item-label-text-color var(--m3e-sys-color-on-surface)
--m3e-comp-standard-menu-item-label-text-color-disabled var(--m3e-sys-color-on-surface)
--m3e-comp-standard-menu-item-label-text-color-selected var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-standard-menu-item-leading-icon-color var(--m3e-sys-color-on-surface-variant)
--m3e-comp-standard-menu-item-leading-icon-color-selected var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-standard-menu-item-trailing-icon-color var(--m3e-sys-color-on-surface-variant)
--m3e-comp-standard-menu-item-trailing-icon-color-selected var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-vibrant-menu-container-color var(--m3e-sys-color-tertiary-container)
--m3e-comp-vibrant-menu-item-container-color-selected var(--m3e-sys-color-tertiary)
--m3e-comp-vibrant-menu-item-label-text-color var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-vibrant-menu-item-label-text-color-disabled var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-vibrant-menu-item-label-text-color-selected var(--m3e-sys-color-on-tertiary)
--m3e-comp-vibrant-menu-item-leading-icon-color var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-vibrant-menu-item-leading-icon-color-disabled var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-vibrant-menu-item-leading-icon-color-selected var(--m3e-sys-color-on-tertiary)
--m3e-comp-vibrant-menu-item-trailing-icon-color var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-vibrant-menu-item-trailing-icon-color-disabled var(--m3e-sys-color-on-tertiary-container)
--m3e-comp-vibrant-menu-item-trailing-icon-color-selected var(--m3e-sys-color-on-tertiary)

Accessibility

APG menu; typeahead; submenus. Checkable items menuitemcheckbox, selectable menuitemradio (Compose Role.Checkbox / Role.RadioButton), aria-checked.

Every interactive part has a target of at least 48 × 48 px, a visible focus ring for keyboard focus only, and works under forced-colors and prefers-reduced-motion.

Sources

Values come from androidx Compose Material3 1.5.0-beta01 at 19991f2c and from m3.material.io, as cited below from COMPONENT_SPEC §21 and the m3e-css sources.

{Menu,StandardMenu,VibrantMenu,SegmentedMenu}Tokens.kt@19991f2c plus the Menu.kt / MenuDefaults.kt literals Compose renders (components/menu-defaults.css; SOURCES register #38). Expressive item: 44dp min height (SegmentedMenuTokens.Item), 12dp padding, 8dp icon–label space, 4dp outside the item surface, 112–280dp wide, 20dp icons; leading/middle/trailing item shapes medium+extra-small / extra-small, selected medium; groups: standalone large, leading/trailing large+small, middle small, inactive (hovered, then left) small; 2dp group gap; standard = StandardMenuTokens (selected tertiary-container), vibrant = VibrantMenuTokens (selected tertiary). Baseline (DropdownMenu): MenuTokens surface, 8dp vertical padding, 48dp items.

menu.css sources

(compose/material3 @ 19991f2c):

  • Menu.kt: DropdownMenuPopup / DropdownMenuPopupContent (no surface; IntrinsicSize.Max column), DropdownMenuGroup (Surface: group color, shape morph on hover on the fast spatial spring, shadow MenuDefaults.ShadowElevation = MenuTokens.ContainerElevation, vertical padding), DropdownMenuItemContent (outer padding 4dp (+2dp with supporting text) outside the item surface, min height SegmentedMenuTokens.Item, width DropdownMenuItemDefaultMin/MaxWidth, MenuArrangement: leading, label after the icon-label space, trailing at the end; container color on the fast effects spring, shape morph on the fast spatial spring; the leading checked icon expands/shrinks horizontally with a fade; text labelLarge (upstream TODO, the token says bodyLarge), supporting text bodyMedium in the label color (LabelWithSupportingText)), open/close scale ClosedScaleTarget on the fast spatial spring and alpha on the fast effects spring around the transform origin, the non-expressive DropdownMenuItemContent and DropdownMenuContent.
  • MenuDefaults.kt: item shapes (leading/middle/trailing/selected: SegmentedMenuTokens first-child shape + inner corner, item shape, selected shape), group shapes (standalone container shape, leading/trailing large+small, middle group shape, inactive container shape), colors (itemColors = ListTokens on a transparent container; selectableItemColors = StandardMenuTokens; itemVibrantColors / selectableItemVibrantColors = VibrantMenuTokens; disabled containers keep the menu color), DropdownMenuGroupLabel, HorizontalDividerPadding, GroupSpacing = SegmentedMenuTokens.SegmentedGap, Leading/TrailingIconSize (20dp, the size the samples give icons).
  • Group content color: contentColorFor(container) (ColorScheme.kt L1096): surfaceContainerLow → onSurface, tertiaryContainer → onTertiaryContainer.
  • Literals: components/menu-defaults.css (PARITY.md).