Selection
Menus
A list of choices on a temporary surface: standard and vibrant colors, sections, and selectable items.
- COMPONENT_SPEC §21
- CSS · custom elements · React
- Open in the playground
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.
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.
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 getsaria-haspopup,aria-expandedandaria-controls; a click toggles the menu, Enter/Space/ArrowDown open it on the first item, ArrowUp on the last.anchorElementsets 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:
actionon the<m3e-menu-item>(bubbles) withdetail: { value, checked };open-changeon the menu withdetail: { 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).