Menus and contextual actions
Command lists, checked states, submenus and menubars.
Quick start
After the application setup, copy this component into your application. It imports only public packages; the complete live demo later on this page also uses documentation layout helpers.
'use client';
import { useState } from 'react';
import { Menu } from '@fayegram-ai/faye-ui/overlays/menu';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
export function CollectionActions() {
const [result, setResult] = useState('No action selected');
return <>
<Menu.Root><Menu.Trigger render={<Button />}>Collection actions</Menu.Trigger>
<Menu.Portal><Menu.Positioner><Menu.Popup>
<Menu.Item onClick={() => setResult('Rename selected')}>Rename</Menu.Item>
<Menu.Item onClick={() => setResult('Duplicate selected')}>Duplicate</Menu.Item>
<Menu.Separator /><Menu.Item disabled>Share privately</Menu.Item>
</Menu.Popup></Menu.Positioner></Menu.Portal>
</Menu.Root>
<p role="status">{result}</p>
</>;
}Usage contract
Menu and ContextMenu preserve Base UI event and focus behavior. Applications should provide an ordinary action path alongside right-click menus. Checked items and nested menus need explicit state ownership and meaningful labels.
Customization
Use semantic tokens for visual changes, public component parts for structural customization, and explicit props for instance behavior. The surrounding site lets you compare light and dark, different brands and density.
Source and verification
The examples below import actual public package entries. Full source is downloadable. Source coverage does not establish keyboard, hydration or browser compatibility; those require the application test suite.
Menu anatomy and removal focus
Menus use content width with a modest minimum, a consistent leading indicator column when checked/radio items are present, and a directional submenu arrow. Ordinary and checked labels share the same start edge. Single-line rows measure 32px comfortable and 28px compact; multiline labels can grow. Coarse pointers receive 44px targets.
Keep semantic focus ownership with Base UI. If an action deletes its own trigger, wait for onOpenChangeComplete(false) before removing the record and then focus a surviving row or empty-state action. The workshop demonstrates this with a persistent undo action. Other actions retain ordinary Escape dismissal and trigger focus return.
Anchoring and page scrolling
Menu.Positioner accepts side, align, sideOffset, alignOffset and a separate anchor element/ref. The live examples demonstrate top, bottom and logical inline sides plus a separate bottom/end anchor. Placement flips when the requested side would collide with the viewport. Logical sides follow direction.
Menu and Select default to modal={false} so opening a dropdown leaves the page scrollbar and background scrolling available. Set modal explicitly when the interaction needs a modal dropdown. Portals carry the originating provider/scope theme, color scheme, density, direction and runtime variables. Configuration examples use the themed Select popup; NativeSelect intentionally uses the operating system picker in native form examples.
Context-menu scroll ownership
ContextMenu retains Base UI's modal context-menu behavior, including keyboard and long-press handling. Its portal coordinates the shared scrollbar-preserving lock with Base UI's deferred native lock, then restores the original page styles and offset when the last owner closes. It does not replace context-menu behavior with an ordinary click menu.
Live examples
Try the controls below, then read or copy the complete example to adapt it for your application.
Anchor sides, alignment and offsets
Placement follows logical direction and flips to stay inside the viewport.
Menu with checked state and submenu
Context menu with an ordinary action alternative
Application menubar
Read the complete example
'use client';
import { Check } from '@lucide/icons';
import { Icon } from '@fayegram-ai/faye-ui/core/icon';
import { useRef, useState } from 'react';
import { Menu } from '@fayegram-ai/faye-ui/overlays/menu';
import { ContextMenu } from '@fayegram-ai/faye-ui/overlays/context-menu';
import { Menubar } from '@fayegram-ai/faye-ui/navigation/menubar';
import { Button } from '@fayegram-ai/faye-ui/controls/button';
import { Inline } from '@fayegram-ai/faye-ui/layout';
import { Section, Result } from '../example';
export default function Menus() {
const anchor = useRef<HTMLDivElement>(null);
const [action, setAction] = useState('No action selected'), [checked, setChecked] = useState(true);
return <><Section title="Anchor sides, alignment and offsets"><Inline gap="2" wrap>{(['top','bottom','inline-start','inline-end'] as const).map(side=><Menu.Root key={side}><Menu.Trigger render={<Button/>}>{side} menu</Menu.Trigger><Menu.Portal><Menu.Positioner side={side} align="start" sideOffset={8}><Menu.Popup><Menu.Item onClick={()=>setAction(`${side} selected`)}>Choose from {side}</Menu.Item></Menu.Popup></Menu.Positioner></Menu.Portal></Menu.Root>)}</Inline><div ref={anchor} className="docs-layout-cell" data-menu-anchor>Separate anchor target</div><Menu.Root><Menu.Trigger render={<Button/>}>Open anchored menu</Menu.Trigger><Menu.Portal><Menu.Positioner anchor={anchor} side="bottom" align="end" sideOffset={8} alignOffset={0}><Menu.Popup><Menu.Item onClick={()=>setAction('Anchored action')}>Action at the anchor</Menu.Item></Menu.Popup></Menu.Positioner></Menu.Portal></Menu.Root><p>Placement follows logical direction and flips to stay inside the viewport.</p></Section><Section title="Menu with checked state and submenu"><Menu.Root><Menu.Trigger render={<Button />}>Project actions</Menu.Trigger><Menu.Portal><Menu.Positioner><Menu.Popup><Menu.Group><Menu.GroupLabel>Document</Menu.GroupLabel><Menu.Item onClick={() => setAction('Duplicated')}>Duplicate</Menu.Item><Menu.Item disabled>Publish (unavailable)</Menu.Item></Menu.Group><Menu.Separator /><Menu.CheckboxItem checked={checked} onCheckedChange={setChecked}><Menu.CheckboxItemIndicator><Icon icon={Check} size="sm"/></Menu.CheckboxItemIndicator>Show details</Menu.CheckboxItem><Menu.RadioGroup defaultValue="list"><Menu.RadioItem value="list"><Menu.RadioItemIndicator><Icon icon={Check} size="sm"/></Menu.RadioItemIndicator>List view</Menu.RadioItem><Menu.RadioItem value="grid"><Menu.RadioItemIndicator><Icon icon={Check} size="sm"/></Menu.RadioItemIndicator>Grid view</Menu.RadioItem></Menu.RadioGroup><Menu.SubmenuRoot><Menu.SubmenuTrigger>Move to…</Menu.SubmenuTrigger><Menu.Portal><Menu.Positioner><Menu.Popup>{['Research', 'Design', 'Archive'].map(x => <Menu.Item key={x} onClick={() => setAction(`Moved to ${x}`)}>{x}</Menu.Item>)}</Menu.Popup></Menu.Positioner></Menu.Portal></Menu.SubmenuRoot></Menu.Popup></Menu.Positioner></Menu.Portal></Menu.Root><Result>{action}; details {checked ? 'visible' : 'hidden'}</Result></Section><Section title="Context menu with an ordinary action alternative"><ContextMenu.Root><ContextMenu.Trigger className="docs-layout-cell" tabIndex={0}>Right-click or use the context-menu key.</ContextMenu.Trigger><ContextMenu.Portal><ContextMenu.Positioner><ContextMenu.Popup><ContextMenu.Group><ContextMenu.GroupLabel>Local actions</ContextMenu.GroupLabel><ContextMenu.Item onClick={() => setAction('Copied link')}>Copy local link</ContextMenu.Item><ContextMenu.LinkItem href="/docs/overview/">Documentation home</ContextMenu.LinkItem></ContextMenu.Group><ContextMenu.Separator/><ContextMenu.CheckboxItem checked={checked} onCheckedChange={setChecked}><ContextMenu.CheckboxItemIndicator><Icon icon={Check} size="sm"/></ContextMenu.CheckboxItemIndicator>Show context details</ContextMenu.CheckboxItem><ContextMenu.RadioGroup defaultValue="regular"><ContextMenu.RadioItem value="regular"><ContextMenu.RadioItemIndicator><Icon icon={Check} size="sm"/></ContextMenu.RadioItemIndicator>Regular view</ContextMenu.RadioItem></ContextMenu.RadioGroup><ContextMenu.SubmenuRoot><ContextMenu.SubmenuTrigger>Move local item</ContextMenu.SubmenuTrigger><ContextMenu.Portal><ContextMenu.Positioner><ContextMenu.Popup><ContextMenu.Item onClick={() => setAction('Archived')}>Archive locally</ContextMenu.Item></ContextMenu.Popup></ContextMenu.Positioner></ContextMenu.Portal></ContextMenu.SubmenuRoot></ContextMenu.Popup></ContextMenu.Positioner></ContextMenu.Portal></ContextMenu.Root><Button onClick={() => setAction('Copied link')}>Copy local link</Button></Section><Section title="Application menubar"><Menubar>{['File', 'Edit'].map(label => <Menu.Root key={label}><Menu.Trigger>{label}</Menu.Trigger><Menu.Portal><Menu.Positioner><Menu.Popup><Menu.Item onClick={() => setAction(`${label} action`)}>Example action</Menu.Item><Menu.LinkItem href="/docs/overview/">Documentation home</Menu.LinkItem></Menu.Popup></Menu.Positioner></Menu.Portal></Menu.Root>)}</Menubar></Section></>;
}