Open menu
import {
CreditCardIcon,
LogOutIcon,
SettingsIcon,
UserIcon,
} from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import {
MenuContent,
MenuItem,
MenuSection,
MenuSeparator,
MenuShortcut,
MenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuDemo () {
return (
< MenuTrigger >
< Button variant = "outline" >Open menu</ Button >
< MenuContent className = "w-56" >
< MenuSection title = "My account" >
< MenuItem textValue = "Profile" >
< UserIcon /> Profile < MenuShortcut >⇧⌘P</ MenuShortcut >
</ MenuItem >
< MenuItem textValue = "Billing" >
< CreditCardIcon /> Billing < MenuShortcut >⌘B</ MenuShortcut >
</ MenuItem >
< MenuItem textValue = "Settings" >
< SettingsIcon /> Settings < MenuShortcut >⌘S</ MenuShortcut >
</ MenuItem >
</ MenuSection >
< MenuSeparator />
< MenuItem textValue = "Log out" variant = "destructive" >
< LogOutIcon /> Log out
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/menu
The CLI installs dependencies and any other components this one uses.
import {
MenuContent,
MenuItem,
MenuSection,
MenuSeparator,
MenuShortcut,
MenuTrigger,
SubmenuTrigger,
} from "@/components/ui/menu" ;
< MenuTrigger >
< Button variant = "outline" >Actions</ Button >
< MenuContent onAction = {( key ) => run (key)}>
< MenuItem id = "edit" >Edit</ MenuItem >
< MenuItem id = "duplicate" >Duplicate</ MenuItem >
< MenuSeparator />
< MenuItem id = "delete" variant = "destructive" >
Delete
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
MenuTrigger connects the first child (any pressable, usually a Button ) to the MenuContent after it. Handle actions with onAction on MenuContent, which receives the item's id, or with onAction on each MenuItem.
Set textValue when children aren't plain text
Typeahead and the item's text for assistive tech come from textValue.
MenuItem fills it in when its children are a plain string; as soon as you
add an icon or a shortcut, pass textValue yourself.
Menu — a list of actions ("Rename", "Delete") or view options ("Sort by", "Show columns") that don't need to be visible all the time.
Select — choosing a form value that's shown in the trigger and submitted.
Command Palette — a searchable list of every action in the app.
Toggle Button Group — a few options that should stay visible.
Popover — anything that isn't a list, like a small form.
< MenuTrigger >
< Button /> { /* trigger */ }
< MenuContent > { /* popover + role="menu" */ }
< MenuSection title = "…" > { /* optional group with a heading */ }
< MenuItem > { /* role="menuitem" */ }
< Icon />
Label
< MenuShortcut /> { /* keyboard hint */ }
</ MenuItem >
</ MenuSection >
< MenuSeparator />
< SubmenuTrigger >
< MenuItem /> { /* opens the submenu */ }
< MenuContent /> { /* nested menu */ }
</ SubmenuTrigger >
</ MenuContent >
</ MenuTrigger >
Part Renders Notes MenuTrigger— Opens the menu on press (or long press / right click) and wires up the trigger. From React Aria. MenuContentPopover › <div role="menu">The positioned popover (min-w-44) and the scrollable menu inside it. MenuItem<div role="menuitem">An action. Becomes menuitemradio / menuitemcheckbox with selection, and an <a> with href. Adds a selection indicator and a submenu chevron automatically. MenuSection<section role="group">Groups items under an optional title. Can have its own selection. MenuSeparator<hr>A 1px divider between items or sections. MenuShortcut<kbd>Right-aligned shortcut hint, linked to the item for assistive tech. SubmenuTrigger— Wraps a MenuItem and the nested MenuContent it opens. From React Aria.
MenuSection groups related items under a small uppercase title, and MenuSeparator divides groups. Icons in items are sized and muted automatically; MenuShortcut right-aligns a keyboard hint. The demo above shows all three.
Open menu
import {
CreditCardIcon,
LogOutIcon,
SettingsIcon,
UserIcon,
} from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import {
MenuContent,
MenuItem,
MenuSection,
MenuSeparator,
MenuShortcut,
MenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuDemo () {
return (
< MenuTrigger >
< Button variant = "outline" >Open menu</ Button >
< MenuContent className = "w-56" >
< MenuSection title = "My account" >
< MenuItem textValue = "Profile" >
< UserIcon /> Profile < MenuShortcut >⇧⌘P</ MenuShortcut >
</ MenuItem >
< MenuItem textValue = "Billing" >
< CreditCardIcon /> Billing < MenuShortcut >⌘B</ MenuShortcut >
</ MenuItem >
< MenuItem textValue = "Settings" >
< SettingsIcon /> Settings < MenuShortcut >⌘S</ MenuShortcut >
</ MenuItem >
</ MenuSection >
< MenuSeparator />
< MenuItem textValue = "Log out" variant = "destructive" >
< LogOutIcon /> Log out
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
);
}
selectionMode="single" on MenuContent turns items into radio options with a dot indicator. Add disallowEmptySelection for settings like "Sort by" that always have a value, and reflect the choice in the trigger.
import { ArrowDownUpIcon } from "lucide-react" ;
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
const options = [
{ id: "updated" , label: "Last updated" },
{ id: "created" , label: "Date created" },
{ id: "name" , label: "Name" },
{ id: "size" , label: "File size" },
];
export default function MenuSingleSelection () {
const [ selected , setSelected ] = useState < Selection >( new Set ([ "updated" ]));
const [ key ] = selected === "all" ? [] : selected;
const current = options. find (( o ) => o.id === key);
return (
< MenuTrigger >
< Button variant = "outline" >
< ArrowDownUpIcon /> Sort: {current?.label}
</ Button >
< MenuContent
aria-label = "Sort by"
items = {options}
selectionMode = "single"
disallowEmptySelection
selectedKeys = {selected}
onSelectionChange = {setSelected}
>
{( o ) => < MenuItem >{o.label}</ MenuItem >}
</ MenuContent >
</ MenuTrigger >
);
}
Each MenuSection can have its own selectionMode, selectedKeys and onSelectionChange, mixing checkbox and radio groups in one menu. Multiple-selection menus stay open when an item is clicked, so several options can be toggled at once.
View options
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import {
MenuContent,
MenuItem,
MenuSection,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuSelection () {
const [ panels , setPanels ] = useState < Selection >( new Set ([ "status" ]));
const [ position , setPosition ] = useState < Selection >( new Set ([ "bottom" ]));
return (
< MenuTrigger >
< Button variant = "outline" >View options</ Button >
< MenuContent className = "w-56" >
< MenuSection
title = "Panels"
selectionMode = "multiple"
selectedKeys = {panels}
onSelectionChange = {setPanels}
>
< MenuItem id = "status" >Status bar</ MenuItem >
< MenuItem id = "activity" >Activity bar</ MenuItem >
< MenuItem id = "panel" >Panel</ MenuItem >
</ MenuSection >
< MenuSeparator />
< MenuSection
title = "Position"
selectionMode = "single"
selectedKeys = {position}
onSelectionChange = {setPosition}
>
< MenuItem id = "top" >Top</ MenuItem >
< MenuItem id = "bottom" >Bottom</ MenuItem >
< MenuItem id = "right" >Right</ MenuItem >
</ MenuSection >
</ MenuContent >
</ MenuTrigger >
);
}
Use React Aria's Text with slot="label" and slot="description" for two-line items. The slots wire up aria-labelledby and aria-describedby, so the description is announced separately from the name.
import {
ChevronDownIcon,
EyeIcon,
MessageSquareIcon,
PencilIcon,
} from "lucide-react" ;
import { useState } from "react" ;
import { type Selection, Text } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
const roles = [
{
id: "edit" ,
label: "Can edit" ,
description: "Make any change, invite others" ,
icon: PencilIcon,
},
{
id: "comment" ,
label: "Can comment" ,
description: "Add comments and suggestions" ,
icon: MessageSquareIcon,
},
{
id: "view" ,
label: "Can view" ,
description: "Read only, no comments" ,
icon: EyeIcon,
},
];
export default function MenuRichItems () {
const [ selected , setSelected ] = useState < Selection >( new Set ([ "comment" ]));
const [ key ] = selected === "all" ? [] : selected;
const current = roles. find (( r ) => r.id === key);
return (
< MenuTrigger >
< Button variant = "ghost" size = "sm" >
{current?.label} < ChevronDownIcon />
</ Button >
< MenuContent
aria-label = "Permission"
items = {roles}
selectionMode = "single"
disallowEmptySelection
selectedKeys = {selected}
onSelectionChange = {setSelected}
placement = "bottom end"
className = "w-72"
>
{( role ) => (
< MenuItem textValue = {role.label} className = "items-start" >
< role.icon className = "mt-0.5" />
< span className = "flex flex-col gap-0.5" >
< Text slot = "label" className = "font-medium" >
{role.label}
</ Text >
< Text
slot = "description"
className = "text-muted-foreground text-xs"
>
{role.description}
</ Text >
</ span >
</ MenuItem >
)}
</ MenuContent >
</ MenuTrigger >
);
}
Pass items and a render function instead of static children. Each item needs an id (or key) property; onAction receives it.
import { FolderIcon, FolderInputIcon } from "lucide-react" ;
import { useState } from "react" ;
import { Button } from "@/components/ui/button" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
const folders = [
{ id: "inbox" , name: "Inbox" },
{ id: "clients" , name: "Clients" },
{ id: "invoices" , name: "Invoices 2026" },
{ id: "receipts" , name: "Receipts" },
{ id: "archive" , name: "Archive" },
];
export default function MenuDynamic () {
const [ moved , setMoved ] = useState < string >();
return (
< div className = "flex flex-col items-center gap-3" >
< MenuTrigger >
< Button variant = "outline" >
< FolderInputIcon /> Move to…
</ Button >
< MenuContent
items = {folders}
onAction = {( key ) => setMoved (folders. find (( f ) => f.id === key)?.name)}
>
{( folder ) => (
< MenuItem textValue = {folder.name}>
< FolderIcon /> {folder.name}
</ MenuItem >
)}
</ MenuContent >
</ MenuTrigger >
{moved && (
< p className = "text-muted-foreground text-sm" >
Moved 3 files to {moved}
</ p >
)}
</ div >
);
}
Wrap a MenuItem and a nested MenuContent in SubmenuTrigger. The item gets a chevron, opens on hover (after a 200ms delay), → or Enter , and closes with ← .
import {
CopyIcon,
FilePlusIcon,
LinkIcon,
MailIcon,
MoreHorizontalIcon,
Share2Icon,
Trash2Icon,
} from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import {
MenuContent,
MenuItem,
MenuSeparator,
MenuTrigger,
SubmenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuSubmenu () {
return (
< MenuTrigger >
< Button variant = "outline" size = "icon" aria-label = "More actions" >
< MoreHorizontalIcon />
</ Button >
< MenuContent >
< MenuItem textValue = "New file" >
< FilePlusIcon /> New file
</ MenuItem >
< MenuItem textValue = "Duplicate" >
< CopyIcon /> Duplicate
</ MenuItem >
< SubmenuTrigger >
< MenuItem textValue = "Share" >
< Share2Icon /> Share
</ MenuItem >
< MenuContent >
< MenuItem textValue = "Copy link" >
< LinkIcon /> Copy link
</ MenuItem >
< MenuItem textValue = "Email" >
< MailIcon /> Email
</ MenuItem >
< MenuItem >Slack</ MenuItem >
< MenuItem >Microsoft Teams</ MenuItem >
</ MenuContent >
</ SubmenuTrigger >
< MenuSeparator />
< MenuItem textValue = "Delete" variant = "destructive" >
< Trash2Icon /> Delete
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
);
}
Give an item an href and it renders as a link, with target, rel and download passed through. It works with client-side routers through React Aria's RouterProvider.
import {
BookOpenIcon,
CircleHelpIcon,
ExternalLinkIcon,
KeyboardIcon,
MessageCircleIcon,
} from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import {
MenuContent,
MenuItem,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuLinks () {
return (
< MenuTrigger >
< Button variant = "ghost" size = "icon" aria-label = "Help" >
< CircleHelpIcon />
</ Button >
< MenuContent placement = "bottom end" className = "w-56" >
< MenuItem
textValue = "Documentation"
href = "https://example.com/docs"
target = "_blank"
>
< BookOpenIcon /> Documentation
< ExternalLinkIcon className = "ml-auto" />
</ MenuItem >
< MenuItem textValue = "Keyboard shortcuts" href = "#keyboard" >
< KeyboardIcon /> Keyboard shortcuts
</ MenuItem >
< MenuSeparator />
< MenuItem textValue = "Contact support" href = "mailto:support@example.com" >
< MessageCircleIcon /> Contact support
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
);
}
disabledKeys on MenuContent (or isDisabled on a MenuItem) keeps an item visible but unfocusable. Explain why nearby when it isn't obvious.
Invoice INV-2041 Paid invoices are locked
import {
ArchiveIcon,
ChevronDownIcon,
CopyIcon,
PencilIcon,
SendIcon,
} from "lucide-react" ;
import { Button } from "@/components/ui/button" ;
import {
MenuContent,
MenuItem,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuDisabled () {
return (
< div className = "flex items-center gap-3" >
< MenuTrigger >
< Button variant = "outline" >
Invoice INV-2041 < ChevronDownIcon />
</ Button >
< MenuContent disabledKeys = {[ "edit" , "send" ]} className = "w-52" >
< MenuItem id = "edit" textValue = "Edit" >
< PencilIcon /> Edit
</ MenuItem >
< MenuItem id = "send" textValue = "Send reminder" >
< SendIcon /> Send reminder
</ MenuItem >
< MenuItem id = "duplicate" textValue = "Duplicate" >
< CopyIcon /> Duplicate
</ MenuItem >
< MenuSeparator />
< MenuItem id = "archive" textValue = "Archive" >
< ArchiveIcon /> Archive
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
< span className = "text-muted-foreground text-xs" >
Paid invoices are locked
</ span >
</ div >
);
}
The menu opens bottom start by default and flips when there isn't room. Use placement="bottom end" for triggers on the right edge of a layout, like a row's "more" button.
bottom start bottom end top start right top
import { Button } from "@/components/ui/button" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
const placements = [
"bottom start" ,
"bottom end" ,
"top start" ,
"right top" ,
] as const ;
export default function MenuPlacement () {
return (
< div className = "grid grid-cols-2 gap-2" >
{placements. map (( placement ) => (
< MenuTrigger key = {placement}>
< Button variant = "outline" className = "capitalize" >
{placement}
</ Button >
< MenuContent placement = {placement}>
< MenuItem >Rename</ MenuItem >
< MenuItem >Move to…</ MenuItem >
< MenuItem >Download</ MenuItem >
</ MenuContent >
</ MenuTrigger >
))}
</ div >
);
}
trigger="contextMenu" opens the menu at the pointer on right click, on long press for touch, and with Shift +F10 on Windows. The trigger must be a focusable React Aria element, like a Button.
Quarterly report.pdf Right-click or long-press
import {
CopyIcon,
DownloadIcon,
FileTextIcon,
PencilIcon,
Trash2Icon,
} from "lucide-react" ;
import { Button } from "react-aria-components" ;
import {
MenuContent,
MenuItem,
MenuSeparator,
MenuShortcut,
MenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuContextMenu () {
return (
< MenuTrigger trigger = "contextMenu" >
< Button className = "flex w-64 cursor-default flex-col items-center gap-2 rounded-xl border border-dashed bg-card p-6 text-sm outline-none data-focus-visible:ring-[3px] data-focus-visible:ring-ring/25" >
< FileTextIcon className = "size-8 text-muted-foreground" />
< span className = "font-medium" >Quarterly report.pdf</ span >
< span className = "text-muted-foreground text-xs" >
Right-click or long-press
</ span >
</ Button >
< MenuContent className = "w-52" >
< MenuItem textValue = "Rename" >
< PencilIcon /> Rename < MenuShortcut >F2</ MenuShortcut >
</ MenuItem >
< MenuItem textValue = "Duplicate" >
< CopyIcon /> Duplicate < MenuShortcut >⌘D</ MenuShortcut >
</ MenuItem >
< MenuItem textValue = "Download" >
< DownloadIcon /> Download
</ MenuItem >
< MenuSeparator />
< MenuItem textValue = "Move to trash" variant = "destructive" >
< Trash2Icon /> Move to trash < MenuShortcut >⌫</ MenuShortcut >
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
);
}
A user button that opens upward, sized to the trigger with popoverClassName="w-(--trigger-width)". A React Aria Header shows who's signed in, and a theme submenu uses single selection.
JB Jordan Blake jordan@lumen.app
import {
ChevronsUpDownIcon,
CreditCardIcon,
LogOutIcon,
MonitorIcon,
MoonIcon,
PaletteIcon,
SettingsIcon,
SunIcon,
UserIcon,
} from "lucide-react" ;
import { useState } from "react" ;
import { Button, Header, type Selection } from "react-aria-components" ;
import { Avatar } from "@/components/ui/avatar" ;
import {
MenuContent,
MenuItem,
MenuSection,
MenuSeparator,
MenuShortcut,
MenuTrigger,
SubmenuTrigger,
} from "@/components/ui/menu" ;
export default function MenuRecipeAccount () {
const [ theme , setTheme ] = useState < Selection >( new Set ([ "system" ]));
return (
< MenuTrigger >
< Button className = "flex w-60 items-center gap-2.5 rounded-lg border bg-card p-2 text-left outline-none data-focus-visible:ring-[3px] data-focus-visible:ring-ring/25 data-hovered:bg-muted data-pressed:bg-muted" >
< Avatar alt = "Jordan Blake" fallback = "JB" colorful />
< span className = "min-w-0 flex-1" >
< span className = "block truncate font-medium text-sm" >
Jordan Blake
</ span >
< span className = "block truncate text-muted-foreground text-xs" >
jordan@lumen.app
</ span >
</ span >
< ChevronsUpDownIcon className = "size-4 text-muted-foreground" />
</ Button >
< MenuContent placement = "top start" popoverClassName = "w-(--trigger-width)" >
< MenuSection >
< Header className = "flex items-center gap-2.5 px-2 py-1.5" >
< Avatar size = "sm" alt = "Jordan Blake" fallback = "JB" colorful />
< span className = "min-w-0 text-sm" >
< span className = "block truncate font-medium" >Jordan Blake</ span >
< span className = "block truncate text-muted-foreground text-xs" >
Pro plan
</ span >
</ span >
</ Header >
< MenuItem textValue = "Profile" >
< UserIcon /> Profile < MenuShortcut >⇧⌘P</ MenuShortcut >
</ MenuItem >
< MenuItem textValue = "Billing" >
< CreditCardIcon /> Billing
</ MenuItem >
< MenuItem textValue = "Settings" >
< SettingsIcon /> Settings < MenuShortcut >⌘,</ MenuShortcut >
</ MenuItem >
< SubmenuTrigger >
< MenuItem textValue = "Theme" >
< PaletteIcon /> Theme
</ MenuItem >
< MenuContent
aria-label = "Theme"
selectionMode = "single"
disallowEmptySelection
selectedKeys = {theme}
onSelectionChange = {setTheme}
>
< MenuItem id = "light" textValue = "Light" >
< SunIcon /> Light
</ MenuItem >
< MenuItem id = "dark" textValue = "Dark" >
< MoonIcon /> Dark
</ MenuItem >
< MenuItem id = "system" textValue = "System" >
< MonitorIcon /> System
</ MenuItem >
</ MenuContent >
</ SubmenuTrigger >
</ MenuSection >
< MenuSeparator />
< MenuItem textValue = "Log out" variant = "destructive" >
< LogOutIcon /> Log out
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
);
}
A "more" menu on each row. "Delete…" opens a controlled alert Dialog after the menu closes, and the other actions confirm with a toast . The ellipsis in the label signals that the action asks for more input.
Live Live PR #482 preview
pr-482.lumen.dev
Paused
import {
CopyIcon,
MoreHorizontalIcon,
PencilIcon,
Trash2Icon,
TriangleAlertIcon,
} from "lucide-react" ;
import { useState } from "react" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogIcon,
DialogTitle,
} from "@/components/ui/dialog" ;
import {
MenuContent,
MenuItem,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu" ;
import { toast } from "@/components/ui/toast" ;
const initial = [
{ id: "prod" , name: "Production" , url: "api.lumen.app" , status: "Live" },
{ id: "staging" , name: "Staging" , url: "staging.lumen.app" , status: "Live" },
{
id: "preview" ,
name: "PR #482 preview" ,
url: "pr-482.lumen.dev" ,
status: "Paused" ,
},
];
type Env = ( typeof initial)[ number ];
export default function MenuRecipeRowActions () {
const [ envs , setEnvs ] = useState (initial);
const [ toDelete , setToDelete ] = useState < Env | null >( null );
return (
<>
< ul className = "w-full max-w-md divide-y rounded-lg border bg-card" >
{envs. map (( env ) => (
< li key = {env.id} className = "flex items-center gap-3 px-3 py-2.5" >
< div className = "min-w-0 flex-1" >
< p className = "truncate font-medium text-sm" >{env.name}</ p >
< p className = "truncate text-muted-foreground text-xs" >
{env.url}
</ p >
</ div >
< Badge
size = "sm"
variant = "dot"
color = {env.status === "Live" ? "success" : "warning" }
>
{env.status}
</ Badge >
< MenuTrigger >
< Button
variant = "ghost"
size = "icon-sm"
aria-label = { `Actions for ${ env . name }` }
>
< MoreHorizontalIcon />
</ Button >
< MenuContent
placement = "bottom end"
onAction = {( key ) => {
if (key === "delete" ) setToDelete (env);
else if (key === "copy" ) toast ( `Copied ${ env . url }` );
else toast ( `Editing ${ env . name }` );
}}
>
< MenuItem id = "edit" textValue = "Edit" >
< PencilIcon /> Edit
</ MenuItem >
< MenuItem id = "copy" textValue = "Copy URL" >
< CopyIcon /> Copy URL
</ MenuItem >
< MenuSeparator />
< MenuItem id = "delete" textValue = "Delete…" variant = "destructive" >
< Trash2Icon /> Delete…
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
</ li >
))}
</ ul >
< DialogContent
role = "alertdialog"
size = "sm"
isOpen = {toDelete !== null }
onOpenChange = {( open ) => ! open && setToDelete ( null )}
>
< DialogIcon tone = "danger" >
< TriangleAlertIcon />
</ DialogIcon >
< DialogHeader >
< DialogTitle >Delete {toDelete?.name}?</ DialogTitle >
< DialogDescription >
{toDelete?.url} will stop responding immediately. This can't be
undone.
</ DialogDescription >
</ DialogHeader >
< DialogFooter >
< DialogClose autoFocus >Cancel</ DialogClose >
< Button
color = "danger"
onPress = {() => {
setEnvs (( list ) => list. filter (( e ) => e.id !== toDelete?.id));
setToDelete ( null );
}}
>
Delete
</ Button >
</ DialogFooter >
</ DialogContent >
</>
);
}
A multiple-selection menu that shows and hides table columns. It stays open while toggling, and the table updates live.
Customer Email Plan MRR Acme Corp ops@acme.com Enterprise $4,200 Globex it@globex.io Business $890 Initech admin@initech.co Starter $49
import { Columns3Icon } from "lucide-react" ;
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
const columns = [
{ id: "email" , label: "Email" },
{ id: "plan" , label: "Plan" },
{ id: "mrr" , label: "MRR" },
{ id: "joined" , label: "Joined" },
];
const rows = [
{
name: "Acme Corp" ,
email: "ops@acme.com" ,
plan: "Enterprise" ,
mrr: "$4,200" ,
joined: "Jan 2024" ,
},
{
name: "Globex" ,
email: "it@globex.io" ,
plan: "Business" ,
mrr: "$890" ,
joined: "Jun 2025" ,
},
{
name: "Initech" ,
email: "admin@initech.co" ,
plan: "Starter" ,
mrr: "$49" ,
joined: "Aug 2026" ,
},
];
export default function MenuRecipeColumnVisibility () {
const [ visible , setVisible ] = useState < Selection >(
new Set ([ "email" , "plan" , "mrr" ]),
);
const shown = columns. filter (( c ) => visible === "all" || visible. has (c.id));
return (
< div className = "w-full max-w-xl space-y-2" >
< div className = "flex justify-end" >
< MenuTrigger >
< Button variant = "outline" size = "sm" >
< Columns3Icon /> Columns
</ Button >
< MenuContent
aria-label = "Visible columns"
placement = "bottom end"
items = {columns}
selectionMode = "multiple"
selectedKeys = {visible}
onSelectionChange = {setVisible}
>
{( c ) => < MenuItem >{c.label}</ MenuItem >}
</ MenuContent >
</ MenuTrigger >
</ div >
< div className = "overflow-x-auto rounded-lg border bg-card" >
< table className = "w-full text-left text-sm" >
< thead className = "border-b text-muted-foreground text-xs" >
< tr >
< th className = "px-3 py-2 font-medium" >Customer</ th >
{shown. map (( c ) => (
< th key = {c.id} className = "px-3 py-2 font-medium" >
{c.label}
</ th >
))}
</ tr >
</ thead >
< tbody className = "divide-y" >
{rows. map (( r ) => (
< tr key = {r.name}>
< td className = "px-3 py-2 font-medium" >{r.name}</ td >
{shown. map (( c ) => (
< td key = {c.id} className = "px-3 py-2 text-muted-foreground" >
{r[c.id as keyof typeof r]}
</ td >
))}
</ tr >
))}
</ tbody >
</ table >
</ div >
</ div >
);
}
The trigger gets aria-haspopup="menu" and aria-expanded; the menu is labelled by its trigger unless you pass aria-label.
Opening with the keyboard focuses the first item (↑ focuses the last). Focus returns to the trigger on close.
Items are announced as menuitem, or menuitemradio / menuitemcheckbox with their checked state when the menu or section has a selectionMode.
Section titles label their group. MenuShortcut is linked to its item so the shortcut is announced.
Typeahead jumps to the next item that starts with the typed characters, using textValue.
Context menus don't get aria-haspopup, since they don't open on activation.
Key Action Space / Enter / ↓ Opens the menu and focuses the first item ↑ Opens the menu and focuses the last item ↓ / ↑ Moves focus between items, wrapping at the ends Home / End Focuses the first / last item Enter Runs the focused item and closes the menu Space Runs or toggles the focused item; multiple-selection menus stay open → / ← Opens / closes a submenu (reversed in right-to-left locales) Esc Closes the menu (or the current submenu) Any character Typeahead to a matching item
On each MenuItem:
Attribute Present when data-focusedThe item has focus (keyboard or hover) data-focus-visibleThe item has keyboard focus data-hoveredHovered with a mouse or pen data-pressedBeing pressed data-selectedThe item is selected data-selection-modeThe menu or section has "single" or "multiple" selection data-disabledThe item is disabled data-has-submenuThe item opens a submenu data-openThe item's submenu is open data-variant"default" or "destructive"
MenuContent's menu has data-empty when there are no items; its popover has the popover attributes , including data-trigger="MenuTrigger" or "SubmenuTrigger".
data-slotElement menuThe role="menu" element menu-itemItems menu-sectionSections menu-separatorSeparators menu-shortcutShortcut hints popoverThe surrounding popover
className on MenuContent styles the menu itself (width, padding); popoverClassName styles the popover around it, e.g. w-(--trigger-width) to match the trigger.
Icons inside items are size-4 and text-muted-foreground unless they have their own size-* or text-* class. Destructive items color their icons too.
The menu scrolls within the popover's available height.
MenuItem's className and children accept a function of its state:
< MenuItem className = {({ isFocused }) => (isFocused ? "bg-brand/10" : "" )}>
{({ isSelected }) => (isSelected ? "Pinned" : "Pin" )}
</ MenuItem >
Also accepts every prop of React Aria's Menu .
Also accepts every prop of React Aria's MenuItem , including hover, focus and press handlers.
MenuSeparator accepts every prop of React Aria's Separator . MenuShortcut accepts every prop of React Aria's Keyboard (a <kbd>).
Command Palette — the same items, searchable in a modal.
Select — pick a form value from a list.
Popover — the surface menus render in.
Button — split buttons pair a button with a menu.