Agents (llms.txt)
Octocat

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 origin prop, 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

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.

Extends the PopoverTrigger component.

The button that opens the menu. For nested submenus, prefer 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.

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.

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.

Extends the div element.

Groups related items together with an optional heading.

Extends the div element.

A heading for a section of items.

A horizontal line to separate groups of items.

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.

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>
  );
}

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.

Right-click anywhere here
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

  1. Content Organization:

    • Group related items into sections with headings
    • Reserve the destructive variant for irreversible actions
    • Keep item text concise and scannable
  2. 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
  3. 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