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>>;
// Ref so MenuItems can pass it to FloatingFocusManager's `initialFocus` —
// focusing the input directly avoids FFM's deferred tabbable scan, which
// on iOS races React's autoFocus and drops the soft keyboard.
searchInputRef: React.RefObject<HTMLInputElement | null>;
hasSearchInput: boolean;
setSearchInputEl: (el: HTMLInputElement | null) => 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 = useRef<HTMLInputElement | null>(null);
const [hasSearchInput, setHasSearchInput] = useState(false);
const setSearchInputEl = useCallback((el: HTMLInputElement | null) => {
searchInputRef.current = el;
setHasSearchInput(!!el);
}, []);
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,
// On narrow viewports a 224px-wide submenu can't fit on either side of
// the trigger. Letting `flip` fall back to vertical placements puts it
// below (or above) the trigger inside the parent panel — same approach
// as base-ui's `fallbackAxisSideDirection`.
flipFallbackPlacements: isNested ? ["left-start", "bottom-start", "top-start"] : undefined,
...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: hasSearchInput,
});
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: !hasSearchInput && !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,
hasSearchInput,
setSearchInputEl,
items,
registerItem,
getItemProps: interactions.getItemProps,
}),
[
parent,
isNested,
highlightedIndex,
hasSearchInput,
setSearchInputEl,
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",
"active:bg-background-secondary 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, searchInputRef, hasSearchInput } = 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}
// Point FFM at the search input so it skips the tabbable scan and
// focuses the input directly — keeps iOS gesture context intact.
initialFocus={hasSearchInput ? searchInputRef : 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 active:bg-background-secondary data-highlighted:bg-background-secondary",
destructive: "text-error active:bg-error/10 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.current && e.key !== "Enter") {
searchInputRef.current.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} />;
};
interface MenuSearchInputProps extends React.ComponentPropsWithRef<"input"> {
isLoading?: boolean;
}
const MenuSearchInput = ({
ref: refProp,
onChange,
onKeyDown,
isLoading,
...props
}: MenuSearchInputProps) => {
const internalRef = useRef<HTMLInputElement | null>(null);
const { highlightedIndex, setHighlightedIndex, items, setSearchInputEl, elementsRef } =
useMenuContext();
const popoverCtx = usePopoverContext();
const tree = useFloatingTree();
// Callback ref keeps the menu's searchInputRef in sync as the input mounts
// and unmounts. Using a callback ref (not a useEffect) means the menu's ref
// is set during commit, before FloatingFocusManager's rAF fires — so its
// `initialFocus` ref dereferences cleanly.
const ref = useMergeRefs([refProp, internalRef, setSearchInputEl]);
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}
isLoading={isLoading}
{...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-[calc(100vw-(--spacing(8)))] flex-col rounded-xl p-0 md:max-w-xl"
>
<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