Menu
A floating menu of actions, with optional nested submenus.
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
export default function MenuPreview() {
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Open Menu</Button>
</Menu.Trigger>
<Menu.Items>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Duplicate</Menu.Item>
<Menu.Item>Archive</Menu.Item>
<Menu.Item disabled>Delete</Menu.Item>
</Menu.Items>
</Menu>
);
} Dependencies
Source Code
import {
FloatingList,
FloatingNode,
FloatingTree,
safePolygon,
type UseInteractionsReturn,
useClick,
useDismiss,
useFloatingNodeId,
useFloatingParentNodeId,
useFloatingTree,
useHover,
useInteractions,
useListItem,
useListNavigation,
useMergeRefs,
useRole,
useTypeahead,
} from '@floating-ui/react';
import { CaretRightIcon } from '@phosphor-icons/react/dist/ssr';
import {
createContext,
Fragment,
use,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { useStableCallback } from '@/foundations/hooks/use-stable-callback';
import { Divider } from '@/components/divider';
import {
Popover,
PopoverContext,
type PopoverOrigin,
usePopoverContext,
usePopoverFloating,
} from '@/components/popover';
import { cn, cva } from '@/lib/utils/classnames';
const HOVER_OPEN_DELAY = 75;
const HOVER_CLOSE_DELAY = 150;
type Item = {
id: string;
label: string;
onSelect?: (e: { preventDefault: () => void }) => void;
};
type Items = Record<string, Item>;
interface MenuContextType {
parent: MenuContextType | null;
isNested: boolean;
elementsRef: React.RefObject<(HTMLElement | null)[]>;
highlightedIndex: number | null;
setHighlightedIndex: React.Dispatch<React.SetStateAction<number | null>>;
searchInputRef: HTMLInputElement | null;
setSearchInputRef: (el: HTMLInputElement) => void;
items: Items;
registerItem: (item: Item) => () => void;
getItemProps: UseInteractionsReturn['getItemProps'];
}
const MenuContext = createContext<MenuContextType | null>(null);
const useMenuContext = () => {
const context = use(MenuContext);
if (context == null) {
throw new Error('Menu components must be wrapped in <Menu />');
}
return context;
};
interface MenuProps {
open?: boolean;
onOpenChange?: (open: boolean) => void;
placement?: React.ComponentProps<typeof Popover>['placement'];
offset?: number;
origin?: PopoverOrigin;
modal?: boolean;
children?: React.ReactNode;
}
/**
* Menu is a list of actions presented in a floating panel. Supports nested
* submenus by rendering a `<Menu>` inside another menu's items, with the inner
* menu's trigger being a `<Menu.ItemTrigger>`.
*
* @example
* ```
* <Menu>
* <Menu.Trigger asChild>
* <Button>Open</Button>
* </Menu.Trigger>
* <Menu.Items>
* <Menu.Item onSelect={...}>New file</Menu.Item>
* <Menu>
* <Menu.ItemTrigger>More options</Menu.ItemTrigger>
* <Menu.Items>
* <Menu.Item onSelect={...}>Sub-action</Menu.Item>
* </Menu.Items>
* </Menu>
* </Menu.Items>
* </Menu>
* ```
*/
const Menu = (props: MenuProps) => {
const parentId = useFloatingParentNodeId();
// The root menu wraps its descendants in a FloatingTree so submenus can
// coordinate (sibling close, tree-wide click). Nested menus are already
// inside the tree from the root, so they render as a Fragment.
const Container = parentId === null ? FloatingTree : Fragment;
return (
<Container>
<MenuRoot {...props} />
</Container>
);
};
const MenuRoot = ({
children,
modal = true,
placement: propPlacement,
...props
}: MenuProps) => {
const parent = use(MenuContext);
const parentId = useFloatingParentNodeId();
const isNested = !!parentId;
const [highlightedIndex, setHighlightedIndex] = useState<number | null>(null);
const elementsRef = useRef<(HTMLElement | null)[]>([]);
const [items, setItems] = useState<Items>({});
const labelsRef = useRef<string[]>([]);
const [searchInputRef, setSearchInputRef] = useState<HTMLInputElement | null>(
null
);
useEffect(() => {
labelsRef.current = Object.values(items).map((item) => item.label);
}, [items]);
const registerItem = useCallback((item: Item) => {
setItems((prev) => ({ ...prev, [item.id]: item }));
return () => {
setItems((prev) => {
const { [item.id]: _, ...rest } = prev;
return rest;
});
};
}, []);
const tree = useFloatingTree();
const nodeId = useFloatingNodeId();
const floating = usePopoverFloating({
nodeId,
placement: propPlacement ?? (isNested ? 'right-start' : 'bottom-start'),
offset: isNested ? -4 : 4,
...props,
});
// Hover-to-open is only meaningful for nested menus. safePolygon avoids
// closing the menu when the cursor briefly leaves the trigger to traverse
// toward the submenu.
const hover = useHover(floating.context, {
enabled: isNested,
delay: { open: HOVER_OPEN_DELAY, close: HOVER_CLOSE_DELAY },
handleClose: safePolygon({ blockPointerEvents: true }),
mouseOnly: true,
});
const click = useClick(floating.context, {
event: 'click',
ignoreMouse: isNested,
toggle: !isNested,
});
const role = useRole(floating.context, { role: 'menu' });
const dismiss = useDismiss(floating.context, { bubbles: true });
const listNavigation = useListNavigation(floating.context, {
listRef: elementsRef,
activeIndex: highlightedIndex,
nested: isNested,
onNavigate: setHighlightedIndex,
virtual: !!searchInputRef,
});
const typeahead = useTypeahead(floating.context, {
// Disable typeahead when a search input is present (it owns letter keys)
// or in nested submenus (their parent's typeahead handles letter keys at
// the root level). Floating UI gates by `open` internally, so a closed
// sibling menu won't hijack keystrokes.
enabled: !searchInputRef && !isNested,
listRef: labelsRef,
activeIndex: highlightedIndex,
onMatch: setHighlightedIndex,
});
const interactions = useInteractions([
hover,
click,
role,
dismiss,
listNavigation,
typeahead,
]);
// Tree-wide coordination:
// - 'click' closes every menu in the tree when any item is selected.
// - 'menuopen' lets siblings (same parent, different node) close themselves
// when another submenu opens.
useEffect(() => {
if (!tree) return;
const onTreeClick = () => floating.setOpen(false);
const onSubMenuOpen = (event: { nodeId: string; parentId: string }) => {
if (event.nodeId !== nodeId && event.parentId === parentId) {
floating.setOpen(false);
}
};
tree.events.on('click', onTreeClick);
tree.events.on('menuopen', onSubMenuOpen);
return () => {
tree.events.off('click', onTreeClick);
tree.events.off('menuopen', onSubMenuOpen);
};
}, [tree, nodeId, parentId, floating]);
useEffect(() => {
if (floating.open && tree) {
tree.events.emit('menuopen', { parentId, nodeId });
}
}, [tree, nodeId, parentId, floating.open]);
const popoverContextValue = useMemo(
() => ({
...floating,
...interactions,
modal,
}),
[floating, interactions, modal]
);
const menuContextValue = useMemo<MenuContextType>(
() => ({
parent,
isNested,
elementsRef,
highlightedIndex,
setHighlightedIndex,
searchInputRef,
setSearchInputRef,
items,
registerItem,
getItemProps: interactions.getItemProps,
}),
[
parent,
isNested,
highlightedIndex,
searchInputRef,
items,
registerItem,
interactions.getItemProps,
]
);
return (
<FloatingNode id={nodeId}>
<PopoverContext value={popoverContextValue}>
<MenuContext value={menuContextValue}>{children}</MenuContext>
</PopoverContext>
</FloatingNode>
);
};
interface MenuTriggerProps extends React.ComponentPropsWithRef<'button'> {
asChild?: boolean;
}
/**
* The trigger element that opens the menu. For root menus this is the entry
* point users click. For nested menus, prefer `<Menu.ItemTrigger>` which
* styles it as a menu item with a chevron.
*/
const MenuTrigger = ({
ref: refProp,
asChild,
children,
className,
...props
}: MenuTriggerProps) => {
const popover = usePopoverContext();
const { isNested, parent } = useMenuContext();
const item = useListItem();
const ref = useMergeRefs([popover.refs.setReference, item.ref, refProp]);
const isHighlighted = parent?.highlightedIndex === item.index;
const Comp = asChild ? Slot : 'button';
// When nested, the trigger is also an item of the parent menu — so it must
// pick up parent's getItemProps for keyboard nav and registration.
const referenceProps = popover.getReferenceProps(
isNested ? parent?.getItemProps(props) : props
);
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
className={cn(!asChild && 'disabled:opacity-40', className)}
tabIndex={isNested ? (isHighlighted ? 0 : -1) : 0}
data-state={popover.open ? 'open' : 'closed'}
data-highlighted={isHighlighted || undefined}
{...referenceProps}
>
{children}
</Comp>
);
};
const itemTriggerStyle = cva({
base: [
'relative mx-(--inset) flex w-[calc(100%-calc(var(--inset)*2))] cursor-pointer select-none items-center gap-1.5 rounded-lg px-3 py-1.5',
'font-medium text-base text-foreground/80 outline-none',
'first-of-type:mt-(--inset) last-of-type:mb-(--inset)',
'data-disabled:pointer-events-none data-disabled:opacity-50',
'data-[state=open]:bg-background-secondary data-highlighted:bg-background-secondary',
],
});
/**
* A menu item that opens a nested submenu. Must be used inside a nested
* `<Menu>` (i.e. one rendered as a child of `<Menu.Items>`).
*/
const MenuItemTrigger = ({
children,
className,
...props
}: MenuTriggerProps) => {
const { parent } = useMenuContext();
if (!parent) {
throw new Error(
'<Menu.ItemTrigger> must be rendered inside a nested <Menu>.'
);
}
return (
<MenuTrigger asChild {...props}>
<button
type="button"
role="menuitem"
className={itemTriggerStyle({ className })}
>
{children}
<CaretRightIcon className="ml-auto text-foreground-secondary" />
</button>
</MenuTrigger>
);
};
interface MenuItemsProps extends React.ComponentPropsWithRef<'div'> {
/**
* Drop the floating panel and just render the items inline. Use this when
* something else owns the surface — a Dialog for a command palette, a
* Drawer for a mobile menu, a card for embedded actions. You keep all of
* Menu's keyboard nav, search, and item registration; you just don't get
* the popover.
*/
inline?: boolean;
}
const MenuItems = ({
ref: refProp,
children,
className,
inline,
...props
}: MenuItemsProps) => {
const popover = usePopoverContext();
const { isNested, elementsRef } = useMenuContext();
const ref = useMergeRefs([popover.refs.setFloating, refProp]);
if (inline) {
return (
<FloatingList elementsRef={elementsRef}>
<div
ref={ref}
className={cn('font-medium text-foreground outline-none', className)}
{...popover.getFloatingProps(props)}
>
{children}
</div>
</FloatingList>
);
}
return (
<FloatingList elementsRef={elementsRef}>
<Popover.Panel
ref={ref}
context={popover.context}
modal={popover.modal}
isPositioned={popover.isPositioned}
initialFocus={isNested ? -1 : 0}
returnFocus={!isNested}
animate={!isNested}
className={cn(
'z-50 max-h-(--max-height) w-56 scroll-py-(--inset) overflow-auto rounded-xl border border-border bg-background font-medium text-foreground shadow-lg outline-none',
className
)}
{...popover.getFloatingProps(props)}
>
{children}
</Popover.Panel>
</FloatingList>
);
};
const itemStyle = cva({
base: [
'relative mx-(--inset) flex w-[calc(100%-calc(var(--inset)*2))] cursor-pointer select-none items-center gap-1.5 rounded-lg px-3 py-1.5',
'font-medium text-base outline-none',
'first-of-type:mt-(--inset) last-of-type:mb-(--inset)',
'data-disabled:pointer-events-none data-disabled:opacity-50',
],
variants: {
variant: {
default: 'text-foreground/80 data-highlighted:bg-background-secondary',
destructive: 'text-error data-highlighted:bg-error/10',
},
},
defaultVariants: { variant: 'default' },
});
interface MenuItemProps extends React.ComponentPropsWithRef<'button'> {
onSelect?: Item['onSelect'];
asChild?: boolean;
variant?: 'default' | 'destructive';
}
const MenuItem = ({
ref: refProp,
children,
className,
disabled,
variant,
onClick,
onSelect,
onKeyDown,
asChild,
...props
}: MenuItemProps) => {
const itemId = useId();
const innerRef = useRef<HTMLButtonElement | null>(null);
const { registerItem, highlightedIndex, getItemProps, searchInputRef } =
useMenuContext();
const popoverCtx = usePopoverContext();
const tree = useFloatingTree();
const stableOnSelect = useStableCallback(onSelect);
const { ref: listItemRef, index } = useListItem();
const ref = useMergeRefs([listItemRef, refProp, innerRef]);
const isHighlighted = highlightedIndex === index;
const Comp = asChild ? Slot : 'button';
useLayoutEffect(() => {
const text = innerRef.current?.textContent;
if (!text) return;
return registerItem({
id: itemId,
label: text,
onSelect: stableOnSelect,
});
}, [registerItem, itemId, stableOnSelect]);
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(e);
if (e.defaultPrevented) return;
stableOnSelect?.(e);
if (e.defaultPrevented) return;
// Close every menu in the tree (root + any open submenus).
if (tree) {
tree.events.emit('click');
} else {
popoverCtx.setOpen(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
onKeyDown?.(e);
if (e.defaultPrevented) return;
// Native button Enter already triggers click → handleClick → onSelect, so
// we don't handle Enter here. If an item ends up focused while a search
// input is present (rare — usually focus stays on the input in virtual
// mode), forward subsequent keystrokes back to the input so typing keeps
// working.
if (searchInputRef && e.key !== 'Enter') {
searchInputRef.focus();
}
};
return (
<Comp
ref={ref}
type={asChild ? undefined : 'button'}
role="menuitem"
data-item-id={itemId}
data-highlighted={isHighlighted || undefined}
tabIndex={isHighlighted ? 0 : -1}
disabled={disabled || undefined}
data-disabled={disabled || undefined}
className={itemStyle({ variant, className })}
{...getItemProps({
...props,
onKeyDown: handleKeyDown,
onClick: handleClick,
})}
>
{children}
</Comp>
);
};
interface MenuSectionContextType {
setTitleId: (id: string) => void;
}
const MenuSectionContext = createContext<MenuSectionContextType | null>(null);
const MenuSection = ({
children,
className,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const [titleId, setTitleId] = useState<string | undefined>(undefined);
return (
<MenuSectionContext value={{ setTitleId }}>
{/** biome-ignore lint/a11y/useSemanticElements: maintain div */}
<div
role="group"
aria-labelledby={titleId}
className={cn(
'flex flex-col items-stretch border-border not-first:border-t',
className
)}
{...props}
>
{children}
</div>
</MenuSectionContext>
);
};
const MenuHeading = ({
children,
id: propsId,
className,
...props
}: React.ComponentPropsWithRef<'div'>) => {
const generatedId = useId();
const id = propsId ?? generatedId;
const ctx = use(MenuSectionContext);
useLayoutEffect(() => {
if (ctx) ctx.setTitleId(id);
}, [ctx, id]);
return (
<div
id={id}
className={cn(
'px-3.5 pt-3 pb-1 font-medium text-foreground-secondary text-sm',
className
)}
{...props}
>
{children}
</div>
);
};
const MenuDivider = ({
className,
...props
}: React.ComponentPropsWithRef<'div'>) => {
return <Divider className={cn('my-(--inset)', className)} {...props} />;
};
const MenuSearchInput = ({
ref: refProp,
onChange,
onKeyDown,
...props
}: React.ComponentPropsWithRef<'input'>) => {
const internalRef = useRef<HTMLInputElement | null>(null);
const {
highlightedIndex,
setHighlightedIndex,
items,
setSearchInputRef,
elementsRef,
} = useMenuContext();
const popoverCtx = usePopoverContext();
const tree = useFloatingTree();
const ref = useMergeRefs([refProp, internalRef]);
useEffect(() => {
if (internalRef.current) setSearchInputRef(internalRef.current);
}, [setSearchInputRef]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onChange?.(e);
setHighlightedIndex(0);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && highlightedIndex !== null) {
const id = elementsRef.current[highlightedIndex]?.dataset.itemId;
if (id && items[id]) {
items[id].onSelect?.(e);
// Mirror MenuItem.handleClick: close the whole tree on selection
// unless onSelect explicitly preventDefault'd.
if (!e.defaultPrevented) {
if (tree) {
tree.events.emit('click');
} else {
popoverCtx.setOpen(false);
}
}
}
}
onKeyDown?.(e);
};
return (
<Popover.SearchInput
ref={ref}
onChange={handleChange}
onKeyDown={handleKeyDown}
{...props}
/>
);
};
const MenuEmpty = Popover.Empty;
const CompoundMenu = Object.assign(Menu, {
Trigger: MenuTrigger,
ItemTrigger: MenuItemTrigger,
Items: MenuItems,
Item: MenuItem,
Section: MenuSection,
Heading: MenuHeading,
Divider: MenuDivider,
SearchInput: MenuSearchInput,
Empty: MenuEmpty,
});
const useMenuPopoverContext = usePopoverContext;
export { CompoundMenu as Menu, useMenuContext, useMenuPopoverContext }; Features
- Smart Positioning: Automatically adjusts position to stay in view
- Nested Submenus: Linear-style hierarchical menus with hover-to-open
- Keyboard Navigation: Arrow keys, type-ahead, right/left to expand and collapse submenus
- Search: Built-in search input for filtering items
- Mouse Anchoring: Open at the cursor position via the inherited
originprop, useful for right-click context menus - Focus Management: Traps focus within the menu when opened
Anatomy
<Menu>
<Menu.Trigger />
<Menu.Items>
<Menu.SearchInput />
<Menu.Section>
<Menu.Heading />
<Menu.Item />
<Menu.Divider />
</Menu.Section>
<Menu>
<Menu.ItemTrigger />
<Menu.Items>
<Menu.Item />
</Menu.Items>
</Menu>
<Menu.Empty />
</Menu.Items>
</Menu>
API Reference
Menu
Extends the Popover component.
| Prop | Default | Type | Description |
|---|---|---|---|
modal | true | boolean | Whether to trap focus inside the menu. |
placement | - | Placement | The placement of the menu relative to its trigger. Defaults to `bottom-start` for root menus and `right-start` for nested submenus. |
origin | "trigger" | "trigger" | "pointer" | [number, number] | How the menu is anchored. See `Popover` for details. Use `[clientX, clientY]` for right-click context menus. |
onOpenChange | - | (open: boolean) => void | Callback fired when the menu opens or closes. |
Menu.Trigger
Extends the PopoverTrigger component.
The button that opens the menu. For nested submenus, prefer Menu.ItemTrigger.
Menu.ItemTrigger
A menu item that opens a nested submenu. Renders with a chevron and must be used inside a nested <Menu>.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to render the trigger as its child element. |
Menu.Items
Extends the PopoverContent component.
The floating panel containing the menu items. Rendered in a portal and positioned relative to the trigger.
| Prop | Default | Type | Description |
|---|---|---|---|
inline | false | boolean | Render the items inline instead of in a floating panel. Useful when composing `Menu` inside a `Dialog` for a command-palette pattern. Keyboard navigation, search, and item registration still work. |
Menu.Item
Extends the button element.
| Prop | Default | Type | Description |
|---|---|---|---|
asChild | - | boolean | Whether to render the item as its child element. |
variant | "default" | "default" | "destructive" | Visual style. Use `destructive` for actions like delete. |
onSelect | - | (e: { preventDefault: () => void }) => void | Callback fired when the item is selected. Call `e.preventDefault()` to prevent the menu from closing. |
Menu.Section
Extends the div element.
Groups related items together with an optional heading.
Menu.Heading
Extends the div element.
A heading for a section of items.
Menu.Divider
A horizontal line to separate groups of items.
Menu.SearchInput
Extends the PopoverSearchInput component.
A styled input with a search icon, useful for filtering items. Works alongside arrow-key navigation; pressing Enter selects the highlighted item.
Menu.Empty
Extends the PopoverEmpty component.
A styled container for empty state messages.
Examples
Simple
Basic usage with a list of items.
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
export default function MenuPreview() {
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Open Menu</Button>
</Menu.Trigger>
<Menu.Items>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Duplicate</Menu.Item>
<Menu.Item>Archive</Menu.Item>
<Menu.Item disabled>Delete</Menu.Item>
</Menu.Items>
</Menu>
);
} With Sections
Group related items with optional headings and a destructive variant.
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
export default function MenuSectionsPreview() {
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Open Menu</Button>
</Menu.Trigger>
<Menu.Items>
<Menu.Section>
<Menu.Heading>Actions</Menu.Heading>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Duplicate</Menu.Item>
</Menu.Section>
<Menu.Section>
<Menu.Heading>Danger Zone</Menu.Heading>
<Menu.Item>Archive</Menu.Item>
<Menu.Item variant="destructive">Delete</Menu.Item>
</Menu.Section>
</Menu.Items>
</Menu>
);
} With Icons
Add icons to menu items.
import {
ArchiveIcon,
CopyIcon,
PencilSimpleIcon,
TrashIcon,
} from '@phosphor-icons/react/dist/ssr';
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
export default function MenuIconsPreview() {
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Menu with Icons</Button>
</Menu.Trigger>
<Menu.Items>
<Menu.Item>
<PencilSimpleIcon />
Edit
</Menu.Item>
<Menu.Item>
<CopyIcon />
Duplicate
</Menu.Item>
<Menu.Item>
<ArchiveIcon />
Archive
</Menu.Item>
<Menu.Item variant="destructive">
<TrashIcon />
Delete
</Menu.Item>
</Menu.Items>
</Menu>
);
} Destructive
Use the destructive variant for actions that require attention.
import { TrashIcon } from '@phosphor-icons/react/dist/ssr';
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
export default function MenuDestructivePreview() {
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Open Menu</Button>
</Menu.Trigger>
<Menu.Items>
<Menu.Item>Edit</Menu.Item>
<Menu.Item>Duplicate</Menu.Item>
<Menu.Divider />
<Menu.Item variant="destructive">
<TrashIcon />
Delete
</Menu.Item>
</Menu.Items>
</Menu>
);
} Search
Filter menu items with the built-in search input. The host owns the filtering — pass filtered children to Menu.Items based on the input value.
import { useMemo, useState } from 'react';
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
const FRUITS = [
'Apple',
'Banana',
'Blueberry',
'Cherry',
'Grape',
'Mango',
'Orange',
'Peach',
'Pineapple',
'Strawberry',
'Watermelon',
];
export default function MenuSearchPreview() {
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return FRUITS;
return FRUITS.filter((f) => f.toLowerCase().includes(q));
}, [query]);
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Pick a fruit</Button>
</Menu.Trigger>
<Menu.Items>
<Menu.SearchInput
placeholder="Search fruits..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
{filtered.map((fruit) => (
<Menu.Item key={fruit}>{fruit}</Menu.Item>
))}
{filtered.length === 0 && <Menu.Empty>No fruits found</Menu.Empty>}
</Menu.Items>
</Menu>
);
} Nested submenu
Render a <Menu> inside <Menu.Items> and use <Menu.ItemTrigger> as the inner menu’s trigger. Hover or right-arrow opens the submenu, left-arrow or Escape closes it.
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
export default function MenuNestedPreview() {
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Open Menu</Button>
</Menu.Trigger>
<Menu.Items>
<Menu.Item>New file</Menu.Item>
<Menu.Item>Open recent</Menu.Item>
<Menu>
<Menu.ItemTrigger>Share</Menu.ItemTrigger>
<Menu.Items>
<Menu.Item>Copy link</Menu.Item>
<Menu.Item>Email</Menu.Item>
<Menu.Item>Slack</Menu.Item>
</Menu.Items>
</Menu>
<Menu.Divider />
<Menu.Item variant="destructive">Delete</Menu.Item>
</Menu.Items>
</Menu>
);
} Complex menu
Multiple submenus, sections, icons, and a destructive action — a sample of how the primitive scales to deeply nested action menus.
import {
ArchiveIcon,
ArrowUpRightIcon,
CalendarIcon,
CopyIcon,
FlagIcon,
LinkIcon,
PencilSimpleIcon,
PushPinIcon,
TagIcon,
TrashIcon,
UserIcon,
} from '@phosphor-icons/react/dist/ssr';
import { Button } from '@/components/button';
import { Menu } from '@/components/menu';
export default function MenuComplexPreview() {
return (
<Menu>
<Menu.Trigger asChild>
<Button variant="outline">Issue actions</Button>
</Menu.Trigger>
<Menu.Items className="w-64">
<Menu.Item>
<PencilSimpleIcon />
Rename
</Menu.Item>
<Menu.Item>
<CopyIcon />
Duplicate
</Menu.Item>
<Menu.Divider />
<Menu>
<Menu.ItemTrigger>
<UserIcon />
Assign to
</Menu.ItemTrigger>
<Menu.Items>
<Menu.Item>Pedro</Menu.Item>
<Menu.Item>Mariana</Menu.Item>
<Menu.Item>Tomás</Menu.Item>
<Menu.Item>Inês</Menu.Item>
</Menu.Items>
</Menu>
<Menu>
<Menu.ItemTrigger>
<FlagIcon />
Priority
</Menu.ItemTrigger>
<Menu.Items>
<Menu.Item>No priority</Menu.Item>
<Menu.Item>Urgent</Menu.Item>
<Menu.Item>High</Menu.Item>
<Menu.Item>Medium</Menu.Item>
<Menu.Item>Low</Menu.Item>
</Menu.Items>
</Menu>
<Menu>
<Menu.ItemTrigger>
<TagIcon />
Labels
</Menu.ItemTrigger>
<Menu.Items>
<Menu.Item>Bug</Menu.Item>
<Menu.Item>Feature</Menu.Item>
<Menu.Item>Improvement</Menu.Item>
<Menu.Item>Refactor</Menu.Item>
</Menu.Items>
</Menu>
<Menu>
<Menu.ItemTrigger>
<CalendarIcon />
Due date
</Menu.ItemTrigger>
<Menu.Items>
<Menu.Item>Today</Menu.Item>
<Menu.Item>Tomorrow</Menu.Item>
<Menu.Item>Next week</Menu.Item>
<Menu.Divider />
<Menu.Item>Custom…</Menu.Item>
</Menu.Items>
</Menu>
<Menu.Divider />
<Menu>
<Menu.ItemTrigger>
<ArrowUpRightIcon />
Move
</Menu.ItemTrigger>
<Menu.Items>
<Menu.Item>Backlog</Menu.Item>
<Menu.Item>Todo</Menu.Item>
<Menu.Item>In progress</Menu.Item>
<Menu.Item>Done</Menu.Item>
<Menu.Item>Cancelled</Menu.Item>
</Menu.Items>
</Menu>
<Menu.Item>
<PushPinIcon />
Pin
</Menu.Item>
<Menu.Item>
<LinkIcon />
Copy link
</Menu.Item>
<Menu.Item>
<ArchiveIcon />
Archive
</Menu.Item>
<Menu.Divider />
<Menu.Item variant="destructive">
<TrashIcon />
Delete
</Menu.Item>
</Menu.Items>
</Menu>
);
} Command menu (⌘K-style)
Compose Menu with Dialog for a centered command palette. Menu.Items accepts an inline prop that skips the floating panel — keyboard navigation, search, and item registration still work, but the items render as a regular block inside the Dialog.
import {
ArchiveIcon,
BookOpenIcon,
CopyIcon,
EnvelopeIcon,
GearIcon,
HouseIcon,
PencilSimpleIcon,
} from '@phosphor-icons/react/dist/ssr';
import { useMemo, useState } from 'react';
import { Button } from '@/components/button';
import { Dialog } from '@/components/dialog';
import { Menu } from '@/components/menu';
const ACTIONS: {
group: string;
items: { id: string; label: string; icon: React.ReactNode }[];
}[] = [
{
group: 'Actions',
items: [
{ id: 'edit', label: 'Edit', icon: <PencilSimpleIcon /> },
{ id: 'duplicate', label: 'Duplicate', icon: <CopyIcon /> },
{ id: 'archive', label: 'Archive', icon: <ArchiveIcon /> },
],
},
{
group: 'Navigation',
items: [
{ id: 'home', label: 'Go home', icon: <HouseIcon /> },
{ id: 'settings', label: 'Open settings', icon: <GearIcon /> },
],
},
{
group: 'Help',
items: [
{ id: 'docs', label: 'Documentation', icon: <BookOpenIcon /> },
{ id: 'support', label: 'Contact support', icon: <EnvelopeIcon /> },
],
},
];
export default function MenuCmdkPreview() {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return ACTIONS;
return ACTIONS.map((group) => ({
...group,
items: group.items.filter((item) => item.label.toLowerCase().includes(q)),
})).filter((group) => group.items.length > 0);
}, [query]);
const close = () => {
setOpen(false);
setQuery('');
};
return (
<Dialog
open={open}
onOpenChange={(next) => {
setOpen(next);
if (!next) setQuery('');
}}
>
<Dialog.Trigger asChild>
<Button variant="outline">Open command menu</Button>
</Dialog.Trigger>
<Dialog.Content
catchFocus={false}
className="flex h-100 max-h-[70svh] w-full max-w-xl flex-col rounded-xl p-0"
>
<Menu open={open} onOpenChange={setOpen} modal={false}>
<Menu.Trigger className="hidden" />
<Menu.Items inline className="flex h-full flex-col overflow-hidden">
<Menu.SearchInput
autoFocus
placeholder="Type a command or search..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
<div className="flex-1 scroll-py-(--inset) overflow-y-auto py-(--inset)">
{filtered.map((group) => (
<Menu.Section key={group.group}>
<Menu.Heading>{group.group}</Menu.Heading>
{group.items.map((item) => (
<Menu.Item key={item.id} onSelect={close}>
{item.icon}
{item.label}
</Menu.Item>
))}
</Menu.Section>
))}
{filtered.length === 0 && (
<Menu.Empty>No matching commands</Menu.Empty>
)}
</div>
</Menu.Items>
</Menu>
</Dialog.Content>
</Dialog>
);
} Right-click context menu
Combine origin={[clientX, clientY]} with an onContextMenu handler to build a context menu without any extra primitive.
import {
CopyIcon,
PencilSimpleIcon,
ScissorsIcon,
TrashIcon,
} from '@phosphor-icons/react/dist/ssr';
import { useState } from 'react';
import { Menu } from '@/components/menu';
export const meta = { layout: 'centered' } as const;
export default function MenuContextPreview() {
const [pos, setPos] = useState<[number, number] | null>(null);
return (
<>
{/** biome-ignore lint/a11y/noStaticElementInteractions: demo */}
<div
className="flex h-48 w-72 items-center justify-center rounded-xl border border-border border-dashed text-foreground-secondary text-sm"
onContextMenu={(e) => {
e.preventDefault();
setPos([e.clientX, e.clientY]);
}}
>
Right-click anywhere here
</div>
<Menu
open={!!pos}
onOpenChange={(open) => !open && setPos(null)}
origin={pos ?? 'trigger'}
>
<Menu.Trigger className="hidden" />
<Menu.Items>
<Menu.Item>
<ScissorsIcon />
Cut
</Menu.Item>
<Menu.Item>
<CopyIcon />
Copy
</Menu.Item>
<Menu.Item>
<PencilSimpleIcon />
Rename
</Menu.Item>
<Menu.Divider />
<Menu.Item variant="destructive">
<TrashIcon />
Delete
</Menu.Item>
</Menu.Items>
</Menu>
</>
);
} Best Practices
-
Content Organization:
- Group related items into sections with headings
- Reserve the
destructivevariant for irreversible actions - Keep item text concise and scannable
-
Nesting:
- Avoid more than two levels of nesting — it’s hard to navigate
- Place the most-used items at the top level
- Use sections instead of submenus when the count fits
-
Keyboard:
- Arrow keys move highlight; Enter selects
- Right-arrow opens a submenu; Left-arrow / Escape closes it
- Type-ahead jumps to items by their first letter (root menus only)
Previous
Listbox
Next
Modal