Menu
A shadcn-style menu component built with Ark UI primitives.
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuBasicDemo = () => {
const [last, setLast] = useState<string | null>(null);
return (
<div className="flex flex-col gap-2">
<output className="text-muted-foreground text-xs">
Last: {last ?? "—"}
</output>
<Menu
onSelect={(d) => {
setLast(d.value);
}}
>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Open menu
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="new">
<MenuItemText>New file</MenuItemText>
</MenuItem>
<MenuItem value="copy">
<MenuItemText>Copy</MenuItemText>
</MenuItem>
<MenuItem value="paste">
<MenuItemText>Paste</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
</div>
);
};
export default MenuBasicDemo;
Installation
npx shadcn@latest add @ark-cn/menuInstall the dependency required by this primitive:
npm install @ark-ui/reactCopy the component source into your app:
TSXcomponents/ui/menu.tsx
"use client";
import {
Menu as MenuPrimitive,
useMenu,
useMenuContext,
useMenuItemContext,
} from "@ark-ui/react/menu";
import { Portal } from "@ark-ui/react/portal";
import { cn } from "@/lib/utils";
export type MenuProps = MenuPrimitive.RootProps;
export const Menu = ({ positioning, ...props }: MenuProps) => (
<MenuPrimitive.Root
data-slot="menu"
positioning={{ sizeMiddleware: false, ...positioning }}
{...props}
/>
);
export type MenuTriggerProps = MenuPrimitive.TriggerProps;
export const MenuTrigger = ({ className, ...props }: MenuTriggerProps) => (
<MenuPrimitive.Trigger
className={cn(className)}
data-slot="menu-trigger"
{...props}
/>
);
export type MenuContextTriggerProps = MenuPrimitive.ContextTriggerProps;
export const MenuContextTrigger = ({
className,
...props
}: MenuContextTriggerProps) => (
<MenuPrimitive.ContextTrigger
className={cn(className)}
data-slot="menu-context-trigger"
{...props}
/>
);
export type MenuPopupProps = MenuPrimitive.ContentProps & {
arrowClassName?: string;
arrowTipClassName?: string;
disablePortal?: boolean;
positionerClassName?: string;
showArrow?: boolean;
};
export const MenuPopup = ({
arrowClassName,
arrowTipClassName,
children,
className,
disablePortal,
positionerClassName,
showArrow = false,
...contentProps
}: MenuPopupProps) => {
const inner = (
<MenuPrimitive.Positioner
className={cn(!disablePortal && "z-50", positionerClassName)}
data-slot="menu-positioner"
>
<MenuPrimitive.Content
className={cn(
"relative z-[calc(50+var(--layer-index,0))] min-w-40 rounded-md border border-border/80 bg-popover p-1 text-popover-foreground shadow-md outline-none ring-1 ring-border/20",
"transition-opacity duration-150 data-[state=closed]:opacity-0 data-[state=open]:opacity-100",
className,
)}
data-slot="menu-content"
{...contentProps}
>
{showArrow ? (
<MenuPrimitive.Arrow
className={cn(
"[--arrow-background:var(--popover)] [--arrow-size:10px] [--arrow-shadow-color:var(--border)]",
arrowClassName,
)}
data-slot="menu-arrow"
>
<MenuPrimitive.ArrowTip
className={cn(
"border-border border-t border-l",
arrowTipClassName,
)}
data-slot="menu-arrow-tip"
/>
</MenuPrimitive.Arrow>
) : null}
{children}
</MenuPrimitive.Content>
</MenuPrimitive.Positioner>
);
return disablePortal ? inner : <Portal>{inner}</Portal>;
};
export const MenuArrow = ({
className,
...props
}: MenuPrimitive.ArrowProps) => (
<MenuPrimitive.Arrow
className={cn(
"[--arrow-background:var(--popover)] [--arrow-size:10px] [--arrow-shadow-color:var(--border)]",
className,
)}
data-slot="menu-arrow-raw"
{...props}
/>
);
export const MenuArrowTip = ({
className,
...props
}: MenuPrimitive.ArrowTipProps) => (
<MenuPrimitive.ArrowTip
className={cn("border-border border-t border-l", className)}
data-slot="menu-arrow-tip-raw"
{...props}
/>
);
export const MenuPositioner = ({
className,
...props
}: MenuPrimitive.PositionerProps) => (
<MenuPrimitive.Positioner
className={cn(className)}
data-slot="menu-positioner-raw"
{...props}
/>
);
export const MenuContent = ({
className,
...props
}: MenuPrimitive.ContentProps) => (
<MenuPrimitive.Content
className={cn(className)}
data-slot="menu-content-raw"
{...props}
/>
);
export type MenuItemProps = MenuPrimitive.ItemProps;
export const MenuItem = ({ className, ...props }: MenuItemProps) => (
<MenuPrimitive.Item
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
className,
)}
data-slot="menu-item"
{...props}
/>
);
export type MenuItemContextProps = MenuPrimitive.ItemContextProps;
export const MenuItemContext = (props: MenuItemContextProps) => (
<MenuPrimitive.ItemContext {...props} />
);
export type MenuItemTextProps = MenuPrimitive.ItemTextProps;
export const MenuItemText = ({ className, ...props }: MenuItemTextProps) => (
<MenuPrimitive.ItemText
className={cn("flex-1 truncate", className)}
data-slot="menu-item-text"
{...props}
/>
);
export type MenuItemIndicatorProps = MenuPrimitive.ItemIndicatorProps;
export const MenuItemIndicator = ({
className,
...props
}: MenuItemIndicatorProps) => (
<MenuPrimitive.ItemIndicator
className={cn(
"inline-flex size-4 shrink-0 items-center justify-center text-foreground",
className,
)}
data-slot="menu-item-indicator"
{...props}
/>
);
export type MenuCheckboxItemProps = MenuPrimitive.CheckboxItemProps;
export const MenuCheckboxItem = ({
className,
...props
}: MenuCheckboxItemProps) => (
<MenuPrimitive.CheckboxItem
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
className,
)}
data-slot="menu-checkbox-item"
{...props}
/>
);
export type MenuRadioItemGroupProps = MenuPrimitive.RadioItemGroupProps;
export const MenuRadioItemGroup = ({
className,
...props
}: MenuRadioItemGroupProps) => (
<MenuPrimitive.RadioItemGroup
className={cn("flex flex-col gap-0.5", className)}
data-slot="menu-radio-item-group"
{...props}
/>
);
export type MenuRadioItemProps = MenuPrimitive.RadioItemProps;
export const MenuRadioItem = ({ className, ...props }: MenuRadioItemProps) => (
<MenuPrimitive.RadioItem
className={cn(
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
className,
)}
data-slot="menu-radio-item"
{...props}
/>
);
export type MenuItemGroupProps = MenuPrimitive.ItemGroupProps;
export const MenuItemGroup = ({ className, ...props }: MenuItemGroupProps) => (
<MenuPrimitive.ItemGroup
className={cn("flex flex-col gap-0.5", className)}
data-slot="menu-item-group"
{...props}
/>
);
export type MenuItemGroupLabelProps = MenuPrimitive.ItemGroupLabelProps;
export const MenuItemGroupLabel = ({
className,
...props
}: MenuItemGroupLabelProps) => (
<MenuPrimitive.ItemGroupLabel
className={cn(
"px-2 py-1.5 font-medium text-muted-foreground text-xs",
className,
)}
data-slot="menu-item-group-label"
{...props}
/>
);
export type MenuSeparatorProps = MenuPrimitive.SeparatorProps;
export const MenuSeparator = ({ className, ...props }: MenuSeparatorProps) => (
<MenuPrimitive.Separator
className={cn("-mx-1 my-1 h-px bg-border", className)}
data-slot="menu-separator"
{...props}
/>
);
export type MenuTriggerItemProps = MenuPrimitive.TriggerItemProps;
export const MenuTriggerItem = ({
className,
...props
}: MenuTriggerItemProps) => (
<MenuPrimitive.TriggerItem
className={cn(
"relative flex cursor-pointer select-none items-center justify-between gap-2 rounded-sm py-1.5 pe-1.5 ps-2 text-sm outline-none transition-colors",
"data-disabled:pointer-events-none data-disabled:opacity-50",
"data-highlighted:bg-accent data-highlighted:text-accent-foreground",
className,
)}
data-slot="menu-trigger-item"
{...props}
/>
);
export type MenuIndicatorProps = MenuPrimitive.IndicatorProps;
export const MenuIndicator = ({ className, ...props }: MenuIndicatorProps) => (
<MenuPrimitive.Indicator
className={cn(className)}
data-slot="menu-indicator"
{...props}
/>
);
export type MenuContextProps = MenuPrimitive.ContextProps;
export const MenuContext = (props: MenuContextProps) => (
<MenuPrimitive.Context {...props} />
);
export type MenuRootProviderProps = MenuPrimitive.RootProviderProps;
export const MenuRootProvider = (props: MenuRootProviderProps) => (
<MenuPrimitive.RootProvider data-slot="menu-root-provider" {...props} />
);
export { useMenu, useMenuContext, useMenuItemContext };
Update import aliases to match your project setup.
Usage
import * as Menu from "@/components/ui/menu"Read exported parts in src/components/ui/menu.tsx and compose the primitive according to the Ark UI pattern for this component.
Examples
Grouping
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuItemGroup,
MenuItemGroupLabel,
MenuItemText,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
const MenuGroupingDemo = () => (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Grouped items
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItemGroup>
<MenuItemGroupLabel>Account</MenuItemGroupLabel>
<MenuItem value="profile">
<MenuItemText>Profile</MenuItemText>
</MenuItem>
<MenuItem value="billing">
<MenuItemText>Billing</MenuItemText>
</MenuItem>
</MenuItemGroup>
<MenuSeparator />
<MenuItemGroup>
<MenuItemGroupLabel>Support</MenuItemGroupLabel>
<MenuItem value="docs">
<MenuItemText>Docs</MenuItemText>
</MenuItem>
<MenuItem value="contact">
<MenuItemText>Contact</MenuItemText>
</MenuItem>
</MenuItemGroup>
</MenuPopup>
</Menu>
);
export default MenuGroupingDemo;
Links
import { Button } from "@/components/ui/button";
import { Menu, MenuItem, MenuPopup, MenuTrigger } from "@/components/ui/menu";
const MenuLinksDemo = () => (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Links
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem asChild value="docs">
<a href="#menu-docs">Documentation</a>
</MenuItem>
<MenuItem asChild value="repo">
<a href="#menu-repo" rel="noreferrer" target="_blank">
Repository
</a>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuLinksDemo;
Checkbox items
import { CheckIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuCheckboxItem,
MenuItemIndicator,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuCheckboxDemo = () => {
const [showToolbar, setShowToolbar] = useState(true);
const [showStatusBar, setShowStatusBar] = useState(false);
return (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Checkboxes
</Button>
</MenuTrigger>
<MenuPopup>
<MenuCheckboxItem
checked={showToolbar}
onCheckedChange={setShowToolbar}
value="autosave"
>
<MenuItemText>Auto save</MenuItemText>
<MenuItemIndicator>
<CheckIcon aria-hidden className="size-4" />
</MenuItemIndicator>
</MenuCheckboxItem>
<MenuCheckboxItem
checked={showStatusBar}
onCheckedChange={setShowStatusBar}
value="notify"
>
<MenuItemText>Notifications</MenuItemText>
<MenuItemIndicator>
<CheckIcon aria-hidden className="size-4" />
</MenuItemIndicator>
</MenuCheckboxItem>
</MenuPopup>
</Menu>
);
};
export default MenuCheckboxDemo;
Radio items
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItemIndicator,
MenuItemText,
MenuPopup,
MenuRadioItem,
MenuRadioItemGroup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuRadioDemo = () => {
const [theme, setTheme] = useState("light");
return (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Theme
</Button>
</MenuTrigger>
<MenuPopup>
<MenuRadioItemGroup
defaultValue="system"
value={theme}
onValueChange={(details) => setTheme(details.value)}
>
<MenuRadioItem value="light">
<MenuItemText>Light</MenuItemText>
<MenuItemIndicator>
<span className="block size-2 rounded-full bg-current" />
</MenuItemIndicator>
</MenuRadioItem>
<MenuRadioItem value="dark">
<MenuItemText>Dark</MenuItemText>
<MenuItemIndicator>
<span className="block size-2 rounded-full bg-current" />
</MenuItemIndicator>
</MenuRadioItem>
<MenuRadioItem value="system">
<MenuItemText>System</MenuItemText>
<MenuItemIndicator>
<span className="block size-2 rounded-full bg-current" />
</MenuItemIndicator>
</MenuRadioItem>
</MenuRadioItemGroup>
</MenuPopup>
</Menu>
);
};
export default MenuRadioDemo;
Context menu
import {
Menu,
MenuContextTrigger,
MenuItem,
MenuItemText,
MenuPopup,
MenuSeparator,
} from "@/components/ui/menu";
const MenuContextMenuDemo = () => (
<Menu>
<MenuContextTrigger className="w-full max-w-xs rounded-lg border border-dashed border-border p-6 text-center text-muted-foreground text-sm">
Right-click or long-press here
</MenuContextTrigger>
<MenuPopup>
<MenuItem value="back">
<MenuItemText>Back</MenuItemText>
</MenuItem>
<MenuItem value="forward">
<MenuItemText>Forward</MenuItemText>
</MenuItem>
<MenuSeparator />
<MenuItem value="reload">
<MenuItemText>Reload</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuContextMenuDemo;
Nested
import { ChevronRightIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
MenuTriggerItem,
} from "@/components/ui/menu";
const MenuNestedDemo = () => (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Nested
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="one">
<MenuItemText>Item one</MenuItemText>
</MenuItem>
<Menu>
<MenuTriggerItem>
<MenuItemText>More</MenuItemText>
<ChevronRightIcon
aria-hidden
className="size-4 shrink-0 opacity-60"
/>
</MenuTriggerItem>
<MenuPopup>
<MenuItem value="sub-a">
<MenuItemText>Sub item A</MenuItemText>
</MenuItem>
<MenuItem value="sub-b">
<MenuItemText>Sub item B</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
<MenuItem value="two">
<MenuItemText>Item two</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuNestedDemo;
In dialog
Menu inside dialog
Menu uses lazyMount and unmountOnExit so layers clean up when the dialog closes.
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPanel,
DialogPopup,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
const MenuInDialogDemo = () => (
<Dialog>
<DialogTrigger asChild>
<Button size="sm" type="button" variant="outline">
Open dialog with menu
</Button>
</DialogTrigger>
<DialogPopup className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Menu inside dialog</DialogTitle>
<DialogDescription>
Menu uses lazyMount and unmountOnExit so layers clean up when the
dialog closes.
</DialogDescription>
</DialogHeader>
<DialogPanel className="flex justify-start">
<Menu lazyMount unmountOnExit>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Actions
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="edit">
<MenuItemText>Edit</MenuItemText>
</MenuItem>
<MenuItem value="duplicate">
<MenuItemText>Duplicate</MenuItemText>
</MenuItem>
<MenuSeparator />
<MenuItem value="delete">
<MenuItemText>Delete</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
</DialogPanel>
<DialogFooter>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Close
</Button>
</DialogClose>
</DialogFooter>
</DialogPopup>
</Dialog>
);
export default MenuInDialogDemo;
Item dialog
Delete item?
This cannot be undone.
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogDescription,
DialogFooter,
DialogHeader,
DialogPopup,
DialogTitle,
} from "@/components/ui/dialog";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuItemDialogDemo = () => {
const [confirmOpen, setConfirmOpen] = useState(false);
return (
<>
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Danger menu
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="rename">
<MenuItemText>Rename</MenuItemText>
</MenuItem>
<MenuItem
closeOnSelect={false}
value="delete"
onSelect={() => setConfirmOpen(true)}
>
<MenuItemText>Delete...</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
<Dialog
onOpenChange={(details) => setConfirmOpen(details.open)}
open={confirmOpen}
>
<DialogPopup className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Delete item?</DialogTitle>
<DialogDescription>This cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button size="sm" type="button" variant="ghost">
Cancel
</Button>
</DialogClose>
<Button
size="sm"
type="button"
variant="destructive"
onClick={() => setConfirmOpen(false)}
>
Delete
</Button>
</DialogFooter>
</DialogPopup>
</Dialog>
</>
);
};
export default MenuItemDialogDemo;
Controlled
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuControlledDemo = () => {
const [open, setOpen] = useState(false);
return (
<div className="flex flex-col gap-2">
<Button
size="sm"
type="button"
variant="secondary"
onClick={() => setOpen((o) => !o)}
>
Toggle menu
</Button>
<Menu onOpenChange={(d) => setOpen(d.open)} open={open}>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Controlled
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="x">
<MenuItemText>Close from item</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
</div>
);
};
export default MenuControlledDemo;
Context hook
import { Button } from "@/components/ui/button";
import {
Menu,
MenuContext,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuContextHookDemo = () => (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
useMenuContext
</Button>
</MenuTrigger>
<MenuPopup>
<MenuContext>
{(ctx) => (
<div className="border-border border-b px-2 py-1.5 text-muted-foreground text-xs">
open: {String(ctx.open)}
</div>
)}
</MenuContext>
<MenuItem value="item-1">
<MenuItemText>Item 1</MenuItemText>
</MenuItem>
<MenuItem value="item-2">
<MenuItemText>Item 2</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuContextHookDemo;
Positioning
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuPositioningDemo = () => (
<Menu positioning={{ placement: "right-start", gutter: 8 }}>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Opens to the right
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="one">
<MenuItemText>One</MenuItemText>
</MenuItem>
<MenuItem value="two">
<MenuItemText>Two</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuPositioningDemo;
Trigger indicator
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuIndicator,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuTriggerIndicatorDemo = () => (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Options
<MenuIndicator>
<ChevronDownIcon aria-hidden className="size-4 opacity-70" />
</MenuIndicator>
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="alpha">
<MenuItemText>Alpha</MenuItemText>
</MenuItem>
<MenuItem value="beta">
<MenuItemText>Beta</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuTriggerIndicatorDemo;
Close on select
import { Button } from "@/components/ui/button";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const MenuCloseOnSelectDemo = () => (
<Menu closeOnSelect>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
closeOnSelect (default)
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="closes">
<MenuItemText>Closes menu</MenuItemText>
</MenuItem>
<MenuItem closeOnSelect={false} value="stays-open">
<MenuItemText>Stays open</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuCloseOnSelectDemo;
Multiple triggers
Alice Johnson
Hey, can you review the latest PR?
Bob Smith
Meeting notes from today are attached.
Carol Davis
The deploy finished successfully!
import { EllipsisVerticalIcon } from "lucide-react";
import {
Menu,
MenuItem,
MenuItemText,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
const MESSAGE_ROWS = [
{
id: "1",
sender: "Alice Johnson",
preview: "Hey, can you review the latest PR?",
},
{
id: "2",
sender: "Bob Smith",
preview: "Meeting notes from today are attached.",
},
{
id: "3",
sender: "Carol Davis",
preview: "The deploy finished successfully!",
},
] as const;
const MenuMultipleTriggersDemo = () => (
<Menu positioning={{ placement: "right-start" }}>
<div className="flex w-full max-w-md flex-col gap-1">
{MESSAGE_ROWS.map((message) => (
<div
className="flex items-center gap-3 rounded-lg border border-border px-3 py-2"
key={message.id}
>
<div className="min-w-0 flex-1">
<p className="font-semibold text-foreground text-xs">
{message.sender}
</p>
<p className="truncate text-muted-foreground text-xs">
{message.preview}
</p>
</div>
<MenuTrigger
aria-label="Message actions"
className="inline-flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"
type="button"
value={message.id}
>
<EllipsisVerticalIcon className="size-4" />
</MenuTrigger>
</div>
))}
</div>
<MenuPopup>
<MenuItem value="reply">
<MenuItemText>Reply</MenuItemText>
</MenuItem>
<MenuItem value="forward">
<MenuItemText>Forward</MenuItemText>
</MenuItem>
<MenuItem value="archive">
<MenuItemText>Archive</MenuItemText>
</MenuItem>
<MenuSeparator />
<MenuItem value="delete">
<MenuItemText>Delete</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuMultipleTriggersDemo;
Multiple menus
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuIndicator,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const FILE_MENU_ITEMS = [
{ label: "New File", value: "new" },
{ label: "Open...", value: "open" },
{ label: "Save", value: "save" },
] as const;
const EDIT_MENU_ITEMS = [
{ label: "Undo", value: "undo" },
{ label: "Redo", value: "redo" },
{ label: "Cut", value: "cut" },
{ label: "Copy", value: "copy" },
] as const;
const MenuMultipleMenusDemo = () => (
<div className="flex flex-wrap gap-2">
<Menu id="showcase-file">
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
File
<MenuIndicator>
<ChevronDownIcon aria-hidden className="size-4 opacity-70" />
</MenuIndicator>
</Button>
</MenuTrigger>
<MenuPopup>
{FILE_MENU_ITEMS.map((item) => (
<MenuItem key={item.value} value={item.value}>
<MenuItemText>{item.label}</MenuItemText>
</MenuItem>
))}
</MenuPopup>
</Menu>
<Menu id="showcase-edit">
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Edit
<MenuIndicator>
<ChevronDownIcon aria-hidden className="size-4 opacity-70" />
</MenuIndicator>
</Button>
</MenuTrigger>
<MenuPopup>
{EDIT_MENU_ITEMS.map((item) => (
<MenuItem key={item.value} value={item.value}>
<MenuItemText>{item.label}</MenuItemText>
</MenuItem>
))}
</MenuPopup>
</Menu>
</div>
);
export default MenuMultipleMenusDemo;
Context lazy mount
import { ChevronRightIcon } from "lucide-react";
import {
Menu,
MenuContextTrigger,
MenuItem,
MenuItemText,
MenuPopup,
MenuSeparator,
MenuTriggerItem,
} from "@/components/ui/menu";
const MenuContextLazyMountDemo = () => (
<Menu lazyMount unmountOnExit>
<MenuContextTrigger className="flex h-40 w-full max-w-xs items-center justify-center rounded-lg border border-dashed border-border text-muted-foreground text-sm">
Right-click here
</MenuContextTrigger>
<MenuPopup>
<MenuItem value="cut">
<MenuItemText>Cut</MenuItemText>
</MenuItem>
<MenuItem value="copy">
<MenuItemText>Copy</MenuItemText>
</MenuItem>
<MenuItem value="paste">
<MenuItemText>Paste</MenuItemText>
</MenuItem>
<MenuSeparator />
<Menu lazyMount unmountOnExit>
<MenuTriggerItem>
<MenuItemText>Share</MenuItemText>
<ChevronRightIcon aria-hidden className="size-4 opacity-60" />
</MenuTriggerItem>
<MenuPopup>
<MenuItem value="email">
<MenuItemText>Email</MenuItemText>
</MenuItem>
<MenuItem value="message">
<MenuItemText>Message</MenuItemText>
</MenuItem>
<MenuItem value="airdrop">
<MenuItemText>AirDrop</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
</MenuPopup>
</Menu>
);
export default MenuContextLazyMountDemo;
Item context row
import { ChevronDownIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuIndicator,
MenuItem,
MenuItemContext,
MenuItemText,
MenuPopup,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu";
const MenuItemContextRowDemo = () => (
<Menu>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Settings
<MenuIndicator>
<ChevronDownIcon aria-hidden className="size-4 opacity-70" />
</MenuIndicator>
</Button>
</MenuTrigger>
<MenuPopup>
<MenuItem value="profile">
<MenuItemContext>
{(item) => (
<span
className={
item.highlighted ? "text-foreground" : "text-muted-foreground"
}
>
Profile Settings
</span>
)}
</MenuItemContext>
</MenuItem>
<MenuItem value="preferences">
<MenuItemText>Preferences</MenuItemText>
</MenuItem>
<MenuItem value="notifications">
<MenuItemText>Notifications</MenuItemText>
</MenuItem>
<MenuSeparator />
<MenuItem value="logout">
<MenuItemText>Log out</MenuItemText>
</MenuItem>
</MenuPopup>
</Menu>
);
export default MenuItemContextRowDemo;
Select event
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Menu,
MenuIndicator,
MenuItem,
MenuItemText,
MenuPopup,
MenuTrigger,
} from "@/components/ui/menu";
const ACCOUNT_MENU_ITEMS = [
{ label: "My Profile", value: "profile" },
{ label: "Settings", value: "settings" },
{ label: "Billing", value: "billing" },
{ label: "Log out", value: "logout" },
] as const;
const MenuSelectEventDemo = () => {
const [lines, setLines] = useState<string[]>([]);
return (
<div className="flex flex-col gap-2">
<output className="min-h-10 whitespace-pre-wrap text-muted-foreground text-xs">
{lines.length ? lines.join("\n") : "Pick an item..."}
</output>
<Menu
onSelect={(event) => {
setLines((previous) =>
[`root onSelect: ${event.value}`, ...previous].slice(0, 5),
);
}}
>
<MenuTrigger asChild>
<Button size="sm" type="button" variant="outline">
Account
<MenuIndicator>
<ChevronDownIcon aria-hidden className="size-4 opacity-70" />
</MenuIndicator>
</Button>
</MenuTrigger>
<MenuPopup>
{ACCOUNT_MENU_ITEMS.map((item) => (
<MenuItem
key={item.value}
value={item.value}
onSelect={() => {
setLines((previous) =>
[`item onSelect: ${item.label}`, ...previous].slice(0, 5),
);
}}
>
<MenuItemText>{item.label}</MenuItemText>
</MenuItem>
))}
</MenuPopup>
</Menu>
</div>
);
};
export default MenuSelectEventDemo;
API reference
This component mirrors the upstream Ark UI primitive. All props and DOM behavior are defined by Ark unless you see an ark-cn-only row below.
MenuPopup
| Prop | Type | Description |
|---|---|---|
| arrowClassName? | string | Class on the arrow element. |
| arrowTipClassName? | string | Class on the arrow tip. |
| disablePortal? | boolean | Renders content in-place instead of portaled. |
| positionerClassName? | string | Extra class on the positioner wrapper. |
| showArrow? | boolean | Toggles the arrow (default false). |
See the ARK UI documentation for the full API.
Accessibility
See the Ark UI documentation for clarification.