Menu
import { useRef, useState } from 'react'
import { Button, Icon, Menu, Surface, Text } from '@language-lit/material3-expressive'
export function MenuExample() {
const anchorRef = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
const [wordWrap, setWordWrap] = useState(true)
const [autoSave, setAutoSave] = useState(true)
const [lastSelected, setLastSelected] = useState<string | null>(null)
return (
<Surface
as="section"
aria-labelledby="menu-example-title"
color="surface-container-low"
shape="extra-large"
className="menu-example"
>
<Text as="h2" id="menu-example-title" variant="titleLarge" emphasis="emphasized">
Menu
</Text>
<Text as="p" variant="bodyMedium">
A portaled, keyboard-navigable action menu anchored to a trigger the
consumer owns, with a checkable item that keeps the menu open.
</Text>
<div className="menu-example__row">
<Button
ref={anchorRef}
variant="outlined"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen(true)}
>
Open menu
</Button>
{lastSelected != null && (
<Text as="p" variant="bodySmall">
Last selected: {lastSelected}
</Text>
)}
</div>
<Menu
anchorRef={anchorRef}
open={open}
onOpenChange={setOpen}
items={[
{
value: 'copy',
label: 'Copy',
leadingIcon: <Icon source="content_copy" />,
onSelect: () => setLastSelected('Copy'),
},
{
value: 'paste',
label: 'Paste',
leadingIcon: <Icon source="content_paste" />,
onSelect: () => setLastSelected('Paste'),
},
{
value: 'share',
label: 'Share',
leadingIcon: <Icon source="share" />,
trailingIcon: <Icon source="chevron_right" />,
onSelect: () => setLastSelected('Share'),
},
{
value: 'delete',
label: 'Delete',
leadingIcon: <Icon source="delete" />,
disabled: true,
onSelect: () => setLastSelected('Delete'),
},
{
value: 'word-wrap',
label: 'Word wrap',
checked: wordWrap,
onCheckedChange: setWordWrap,
},
{
value: 'auto-save',
label: 'Auto save',
checked: autoSave,
disabled: true,
onCheckedChange: setAutoSave,
},
]}
/>
</Surface>
)
}Menu renders a data-driven, portaled action menu anchored to a trigger you
already render. It is the first component with no native top-layer
primitive to lean on — positioning, outside-click/Escape dismissal, and
focus restoration are all owned by the library.
import { useRef, useState } from 'react'
import { Button, Icon, Menu } from '@language-lit/material3-expressive'
import '@language-lit/material3-expressive/styles.css'
const anchorRef = useRef<HTMLButtonElement>(null)
const [open, setOpen] = useState(false)
<Button ref={anchorRef} aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(true)}>
Actions
</Button>
<Menu
anchorRef={anchorRef}
open={open}
onOpenChange={setOpen}
items={[
{ value: 'copy', label: 'Copy', leadingIcon: <Icon source="content_copy" />, onSelect: handleCopy },
{ value: 'paste', label: 'Paste', onSelect: handlePaste },
]}
/>Contract
open/defaultOpen/onOpenChangefollow the same controlled/ uncontrolled shape as every other stateful component.anchorRefpoints at a trigger you render and own completely —Menurenders no trigger of its own. Your trigger is responsible for its ownaria-haspopup="menu"/aria-expanded/click wiring, the same trigger-agnostic contractDialogalready uses for its own open state.items: readonly MenuItem[]describes the menu's content:{ value, label, onSelect?, leadingIcon?, trailingIcon?, disabled?, checked?, onCheckedChange? }. Omittingcheckedrenders a plainrole="menuitem"that closes the menu on activation. Definingchecked(evenfalse) rendersrole="menuitemcheckbox"witharia-checkedand keeps the menu open on activation, so a consumer can toggle several settings in one visit.
Keyboard
| Key | Behavior |
|---|---|
| Opening | Moves real focus to the first enabled item |
| ArrowDown / ArrowUp | Move focus across enabled items, wrapping |
| Home / End | Jump to the first/last enabled item |
| Typing | Typeahead — jumps to the next item whose (string) label starts with that character |
| Enter / Space | Activates the focused item |
| Escape | Closes and restores focus to the anchor |
| Tab | Closes without trapping focus — the browser's own tab order continues |
Disabled items are skipped by keyboard navigation and inert to click. There is no focus trap and no inert background, matching the APG menu-button pattern rather than Dialog's modal pattern.
Positioning
The popup tries to align its start edge with the anchor's, then its end edge, then clamps inside the viewport — the same for the vertical axis, preferring below the anchor, then above, then clamped. Width stays within 112–280px regardless of anchor width. It repositions on scroll and window resize while open.
Tokens and boundaries
All color, geometry, and motion values live in one --m3e-comp-menu-*
registration, reused unchanged by Select's own popup listbox. Theme
overrides remain scoped to Material3Provider; Menu injects no runtime
styles. It imports no Next.js, Vite, router, animation
library, or private application code.