Context menu
The menu a right-click or a long press opens on a region.
Vibra draws the menu as the kit's elev-1 floating layer and fades it in over --duration-base without shadcn's zoom, sliding 4px from its side rather than 8; items are set in the 13px label register, and a submenu casts --shadow-float. It opens at the pointer on a right-click or a long press, and from the keyboard on the Menu key (or Shift+F10 on Windows and Linux), which send the same event to whatever has the focus; a Mac keyboard has neither. It stays out of sight until asked for, so everything in it needs a visible second way — a ⋯ button, a heading that sorts. Its items name logical sides, and it and its submenus open towards the inline end, so a right-to-left page mirrors it whole.
Install
npx shadcn@latest add @vibra/context-menuNeeds the @vibra registry in your components.json — set it up once.
Examples
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
export default function ContextMenuDemo() {
return (
<ContextMenu>
<ContextMenuTrigger className="flex h-28 w-full max-w-xs items-center justify-center rounded-lg border border-dashed border-input text-sm text-muted-foreground">
Right-click the revenue chart
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuItem>
Copy as image
<ContextMenuShortcut>⌘⇧C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>Download CSV</ContextMenuItem>
<ContextMenuItem>Add to a dashboard</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Remove from overview</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}On a list of files
A right-click or a long press opens a row's actions at the pointer; the same actions sit behind the row's ⋯ button, so the menu is a shortcut, never the only way.
import { DownloadIcon, EllipsisIcon, FileSpreadsheetIcon, FileTextIcon, LinkIcon, PencilIcon, Trash2Icon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
const FILES = [
{ name: "Q3 revenue report.pdf", size: "1.2 MB", icon: FileTextIcon },
{ name: "Churned accounts.csv", size: "84 KB", icon: FileSpreadsheetIcon },
{ name: "Board pack, September.pdf", size: "3.4 MB", icon: FileTextIcon },
]
const ACTIONS = [
{ label: "Rename", icon: PencilIcon },
{ label: "Copy link", icon: LinkIcon },
{ label: "Download", icon: DownloadIcon },
]
// A right-click (or a long press) on a row opens its actions where the
// pointer is. A context menu is a shortcut, never the only way: the same
// actions sit behind the row's ⋯ button. From the keyboard, the Menu key (or
// Shift+F10 on Windows and Linux) on the focused file link opens it too.
export default function ContextMenuFiles() {
return (
<ul aria-label="Reports folder" className="w-full max-w-md divide-y divide-border rounded-lg border border-border">
{FILES.map((file) => (
<li key={file.name} className="flex items-center gap-1 pe-2 has-data-popup-open:bg-muted/50">
<ContextMenu>
<ContextMenuTrigger className="flex min-w-0 flex-1 items-center gap-3 py-2 ps-3">
<file.icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
<a href={`/files/reports/${encodeURIComponent(file.name)}`} className="min-w-0 flex-1 truncate rounded-sm text-sm focus-ring hover:underline">
{file.name}
</a>
<span className="text-xs text-muted-foreground tabular-nums">{file.size}</span>
</ContextMenuTrigger>
<ContextMenuContent className="w-44">
{ACTIONS.map((action) => (
<ContextMenuItem key={action.label}>
<action.icon aria-hidden="true" />
{action.label}
</ContextMenuItem>
))}
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Actions for ${file.name}`} />}>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
{ACTIONS.map((action) => (
<DropdownMenuItem key={action.label}>
<action.icon aria-hidden="true" />
{action.label}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</li>
))}
</ul>
)
}On a table heading
The Amount heading sorts on a press; its menu — a right-click, or the Menu key once it has the focus — holds the order as radio items and the cents as a checkbox.
"use client"
import * as React from "react"
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
const ROWS = [
{ id: "INV-2041", customer: "Harbour Logistics", amount: 4820 },
{ id: "INV-2040", customer: "Keystone Dental", amount: 1290.5 },
{ id: "INV-2039", customer: "Riverside Florists", amount: 12460.25 },
]
const ORDERS = { largest: "descending", smallest: "ascending", invoice: "none" } as const
type Order = keyof typeof ORDERS
// The Amount heading is a button: pressing it sorts, and a right-click — or
// the Menu key once it has the focus — opens every setting for the column:
// one order from three (radio items) and a display toggle (a checkbox item).
export default function ContextMenuTable() {
const [order, setOrder] = React.useState<Order>("invoice")
const [cents, setCents] = React.useState(false)
const rows = order === "invoice" ? ROWS : [...ROWS].sort((a, b) => (order === "largest" ? b.amount - a.amount : a.amount - b.amount))
const digits = cents ? 2 : 0
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", minimumFractionDigits: digits, maximumFractionDigits: digits })
return (
<table className="w-full max-w-md text-sm">
<thead>
<tr className="border-b border-rule text-xs text-muted-foreground">
<th className="py-2 text-start font-medium">Invoice</th>
<th className="py-2 text-start font-medium">Customer</th>
<th aria-sort={ORDERS[order]} className="py-1 text-end font-medium">
<ContextMenu>
<ContextMenuTrigger
render={<button type="button" onClick={() => setOrder(order === "largest" ? "smallest" : "largest")} />}
className="rounded-md px-1.5 py-1 focus-ring hover:text-foreground data-popup-open:bg-muted data-popup-open:text-foreground"
>
Amount
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuRadioGroup value={order} onValueChange={(value) => setOrder(value as Order)}>
<ContextMenuLabel>Sort by amount</ContextMenuLabel>
<ContextMenuRadioItem value="largest">Largest first</ContextMenuRadioItem>
<ContextMenuRadioItem value="smallest">Smallest first</ContextMenuRadioItem>
<ContextMenuRadioItem value="invoice">Invoice order</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuGroup>
<ContextMenuCheckboxItem checked={cents} onCheckedChange={setCents}>
Show cents
</ContextMenuCheckboxItem>
</ContextMenuGroup>
</ContextMenuContent>
</ContextMenu>
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-border last:border-0">
<td className="py-2 font-mono">{row.id}</td>
<td className="py-2 text-muted-foreground">{row.customer}</td>
<td className="py-2 text-end">{money.format(row.amount)}</td>
</tr>
))}
</tbody>
</table>
)
}On a board card
A card's quick edits one level down: its column and its owner are radio groups in submenus, and the card and a status line show the result.
"use client"
import * as React from "react"
import { CircleDashedIcon, LinkIcon, Trash2Icon, UserRoundIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
const STATUSES = ["To do", "In progress", "In review", "Done"]
const PEOPLE = ["Saoirse Byrne", "Tomás Ortega", "Hiro Tanaka"]
function Face({ name }: { name: string }) {
return (
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(name)} alt="" />
<AvatarFallback>{getInitials(name)}</AvatarFallback>
</Avatar>
)
}
// A board card's quick edits, one level down each: its column and its owner
// are choices from a list, so each submenu is a radio group with the current
// one ticked. The card shows the result at once, and a status line says it.
export default function ContextMenuTask() {
const [status, setStatus] = React.useState("In progress")
const [owner, setOwner] = React.useState(PEOPLE[1])
return (
<ContextMenu>
<ContextMenuTrigger className="flex w-full max-w-xs flex-col gap-3 rounded-lg bg-card p-3 ring-1 ring-border data-popup-open:bg-muted">
<a href="/projects/q4-launch/tasks/NW-318" className="rounded-sm text-sm font-medium focus-ring hover:underline">
Send renewal terms to Harbour Logistics
</a>
<span className="flex items-center justify-between gap-2 text-xs text-muted-foreground">
<span role="status">
{status} · {owner}
</span>
<Face name={owner} />
</span>
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuSub>
<ContextMenuSubTrigger>
<CircleDashedIcon aria-hidden="true" />
Move to
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-40">
<ContextMenuRadioGroup value={status} onValueChange={(value) => setStatus(String(value))}>
{STATUSES.map((name) => (
<ContextMenuRadioItem key={name} value={name} closeOnClick>
{name}
</ContextMenuRadioItem>
))}
</ContextMenuRadioGroup>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSub>
<ContextMenuSubTrigger>
<UserRoundIcon aria-hidden="true" />
Assign to
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuRadioGroup value={owner} onValueChange={(value) => setOwner(String(value))}>
{PEOPLE.map((name) => (
<ContextMenuRadioItem key={name} value={name} closeOnClick>
<Face name={name} />
{name}
</ContextMenuRadioItem>
))}
</ContextMenuRadioGroup>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem>
<LinkIcon aria-hidden="true" />
Copy link
<ContextMenuShortcut>⌘L</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">
<Trash2Icon aria-hidden="true" />
Delete task
<ContextMenuShortcut>⌘⌫</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| ContextMenuItem.variant | "default" | "destructive" | "default" | destructive sets the item in the danger tone. |
| ContextMenuItem.inset | boolean | — | Indents the item to line up with items that carry an icon or a check. |
| ContextMenuCheckboxItem.checked / onCheckedChange | boolean / (checked: boolean) => void | — | A toggle; the menu stays open for the next one. |
| ContextMenuRadioItem.closeOnClick | boolean | false | Radio items keep the menu open; pass it when the choice ends the task. |
| ContextMenuContent.side | "inline-end" | "inline-start" | "left" | "right" | … | "inline-end" | Where the menu opens from the pointer: its right, or its left in a right-to-left page. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({ ...props }: ContextMenuPrimitive.Root.Props) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuPortal({ ...props }: ContextMenuPrimitive.Portal.Props) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuTrigger({
className,
...props
}: ContextMenuPrimitive.Trigger.Props) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuContent({
className,
align = "start",
alignOffset = 4,
// Beside the pointer on the inline end: its right, or its left in a
// right-to-left page, as the system's own context menus open.
side = "inline-end",
sideOffset = 0,
...props
}: ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset"
>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Positioner
className="isolate z-50 outline-none"
align={align}
alignOffset={alignOffset}
side={side}
sideOffset={sideOffset}
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
className={cn("z-50 max-h-(--available-height) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto elev-1 p-1 text-popover-foreground outline-none data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-start-1 data-[side=inline-start]:slide-in-from-end-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)", className )}
{...props}
/>
</ContextMenuPrimitive.Positioner>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuGroup({ ...props }: ContextMenuPrimitive.Group.Props) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuLabel({
className,
inset,
...props
}: ContextMenuPrimitive.GroupLabel.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.GroupLabel
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:ps-7",
className
)}
{...props}
/>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: ContextMenuPrimitive.Item.Props & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:ps-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSub({ ...props }: ContextMenuPrimitive.SubmenuRoot.Props) {
return (
<ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:ps-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
{/* At the inline end, pointing the way the submenu opens. */}
<ChevronRightIcon className="ms-auto rtl:rotate-180" />
</ContextMenuPrimitive.SubmenuTrigger>
)
}
function ContextMenuSubContent({
...props
}: React.ComponentProps<typeof ContextMenuContent>) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
className="shadow-(--shadow-float)"
// Logical, so it opens away from its trigger in either direction.
side="inline-end"
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 ps-1.5 pe-8 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute end-2">
<ContextMenuPrimitive.CheckboxItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.CheckboxItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioGroup({
...props
}: ContextMenuPrimitive.RadioGroup.Props) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 ps-1.5 pe-8 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:ps-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute end-2">
<ContextMenuPrimitive.RadioItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuSeparator({
className,
...props
}: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ms-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}