Context Menu
A right-click menu with danger items, checkbox items, radio groups, submenus and shortcuts.
import { Copy, Scissors, Star, Trash2 } from "lucide-react"
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
export default function ContextMenuDemo() {
return (
<ContextMenu>
<ContextMenuTrigger className="flex h-32 w-full max-w-xs items-center justify-center rounded-xl border border-dashed border-border text-sm text-muted-foreground">
Right click here
</ContextMenuTrigger>
<ContextMenuContent className="w-48">
<ContextMenuItem>
<Copy />
Copy
<ContextMenuShortcut>Cmd C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
<Scissors />
Cut
<ContextMenuShortcut>Cmd X</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
<Star />
Add to favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="danger">
<Trash2 />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)
}Installation
Run the following command
npx shadcn@latest add @ai2/context-menuDependencies, the @ai2/tokens theme and the component file are installed together.
Install dependencies
npm install radix-ui@^1.6.1 lucide-react@^1.23.0 tw-animate-css@^1.4.0Add the cn util
lib/utils.tsimport { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/* Adds a source-attribution ref param to a URL (the inspiration exports mark their
outbound links with an ai2.design attribution). An invalid URL is returned as is.
This file is SHOWN TO THE CONSUMER: the docs component pages render the source of
`cn` in a code block, so a Turkish comment here would reach every one of those
pages. Keep it English. */
export function withRef(url: string, ref = "ai2.design"): string {
try {
const u = new URL(url)
u.searchParams.set("ref", ref)
return u.toString()
} catch {
return url
}
}Copy the source code
components/ui/context-menu.tsx"use client"
import type * as React from "react"
import { Check, ChevronRight, Circle } from "lucide-react"
import { ContextMenu as ContextMenuPrimitive } from "@/components/ui/primitives"
import { cn } from "@/lib/utils"
function ContextMenu(
props: React.ComponentProps<typeof ContextMenuPrimitive.Root>
) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger(
props: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>
) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup(
props: React.ComponentProps<typeof ContextMenuPrimitive.Group>
) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
}
function ContextMenuPortal(
props: React.ComponentProps<typeof ContextMenuPrimitive.Portal>
) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub(
props: React.ComponentProps<typeof ContextMenuPrimitive.Sub>
) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup(
props: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>
) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
const menuItemBase =
"relative flex cursor-default select-none items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none transition-colors duration-(--motion-fast) focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground [&_i]:pointer-events-none [&_i]:shrink-0 [&_i]:text-base [&_i]:leading-none [&_i:not([class*='text-'])]:text-muted-foreground"
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
menuItemBase,
"data-[inset]:ps-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
className
)}
{...props}
>
{children}
<ChevronRight className="ms-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
/* Same rationale as Content: because Radix has horizontal shift
disabled, the clamp is not a fixed vw but the available width the
popper computes. */
"z-50 max-w-(--radix-context-menu-content-available-width) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
/* At 320px the menu overflowed the viewport (measured: left 162 +
width 192 = right edge 354, 34px outside).
ROOT CAUSE (verified against the floating-ui source): inside
shift(), `crossAxis = getSideAxis(placement)` and `mainAxis` is its
opposite. When side is "right", mainAxis becomes VERTICAL; Radix
Popper configures shift with `{mainAxis: true, crossAxis: false}`,
so HORIZONTAL shifting is off from the start. collisionPadding
computes the overflow correctly but cannot move the box
horizontally; `sticky` changes the limiter, not the axis (both were
measured, neither changed the result). flip sees 34px of overflow on
both sides when the cursor is dead centre, so it stays on "right" in
the tie.
Since Radix does not expose crossAxis shift, one lever remains:
WIDTH. `--radix-context-menu-content-available-width` is what the
size middleware computes for the final position (142px in that
measurement) and it has already subtracted collisionPadding. A fixed
`calc(100vw-2rem)` could not catch this because the menu (192) stayed
under that clamp (288). */
collisionPadding={16}
className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) max-w-(--radix-context-menu-content-available-width) min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-y-auto overflow-x-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 motion-reduce:animate-none",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "danger"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
menuItemBase,
"data-[inset]:ps-8 data-[variant=danger]:text-danger data-[variant=danger]:focus:bg-danger-soft data-[variant=danger]:focus:text-danger-soft-foreground data-[variant=danger]:*:[svg]:text-danger data-[variant=danger]:*:[i]:text-danger",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(menuItemBase, "py-1.5 ps-8 pe-2", className)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(menuItemBase, "py-1.5 ps-8 pe-2", className)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:ps-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
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",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}Manual installs skip the @ai2/tokens theme, so add the token CSS from the theming guide or the tone colors will be missing.
Usage
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu"
<ContextMenu>
<ContextMenuTrigger className="flex h-32 w-full items-center justify-center rounded-xl border border-dashed">
Right click here
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>Copy</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="danger">Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>The ContextMenuTrigger is the right-click surface itself, so give it a size. The menu opens at the pointer, portals to the body and is fully keyboard navigable once open.
Examples
Item variants and states
Both item variants in one menu: default for plain actions and variant="danger" for destructive ones, plus the inset and disabled states and a right-aligned ContextMenuShortcut.
Checkbox and radio items
Checkbox items toggle independently with checked and onCheckedChange; the radio group gives an exclusive choice via value and onValueChange.
Submenu
Props
All parts forward their radix ContextMenu props: onSelect and disabled on items, onOpenChange on the root. The ai2-specific props:
ContextMenuItem props
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "danger" | "default" | danger colors the item (and its icons) with the danger tone for destructive actions. |
inset | boolean | - | Adds left padding so plain items align with checkbox and radio items. Also available on ContextMenuLabel and ContextMenuSubTrigger. |
ai2 Context Menu: a right-click action menu for React
The ai2 Context Menu is a shadcn-compatible context menu component for React, built on the radix-ui ContextMenu primitive and styled with Tailwind CSS v4. It opens on right-click over any target and covers the whole menu vocabulary in one install: plain actions, a danger variant for destructive items, checkbox items, radio groups, nested submenus, labels, separators and right-aligned shortcut hints.
Because it ships through the shadcn registry format, you install it with one CLI command, an MCP agent, or a copy-paste, and the source lands in your own project. You own the file; there is no runtime dependency on ai2 itself. The live example above is the exact component you get.
What is the ai2 Context Menu?
It is a composition around ContextMenu, ContextMenuTrigger and ContextMenuContent, with ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioGroup and ContextMenuRadioItem for entries, plus label, separator, shortcut and the ContextMenuSub trio for nesting. The anatomy mirrors the ai2 Dropdown Menu, so the same item vocabulary applies; only the trigger differs.
On top of the radix API, items add two ai2 props: variant, where danger colors the item and its icons with the danger tone, and inset, which aligns plain items with checkbox and radio rows. The content portals to the body and animates from the pointer position where the menu opened.
Why use it
- Right-click, done right: The radix-ui ContextMenu primitive replaces the native browser menu on the target, positions the menu at the pointer and handles long-press on touch, so contextual actions feel native without custom event code.
- Accessible by construction: Menu roles, typeahead, focus management and dismissal come from radix: arrow keys move, Enter selects, Escape closes and focus returns to the trigger.
- Danger items built in: variant="danger" styles destructive actions like Delete with the danger token, including their icons and a soft danger highlight on focus, no custom classes needed.
- Stateful items included: Checkbox items with check indicators and exclusive radio groups with dot indicators ship in the same file, so view toggles and sort pickers need no extra components.
- Agent-readable metadata: The registry item describes its parts and intended use in plain words, so an MCP agent can find, inspect and install it without guessing.
Features
- shadcn registry install: One command adds the component, its dependencies and the @ai2/tokens theme to your project.
- Pointer-anchored menu: The content opens at the cursor and portals to the body, so the menu never clips inside a scroll container or an overflow-hidden card.
- Origin-aware animation: Open and close fade and zoom from the menu origin using the radix transform-origin variable, and item highlights run on the --motion-fast token.
- Labels, separators and shortcuts: ContextMenuLabel titles a section, ContextMenuSeparator splits groups, and ContextMenuShortcut right-aligns a keyboard hint inside any item.
- Submenus included: ContextMenuSub with ContextMenuSubTrigger and ContextMenuSubContent gives hover and keyboard-openable nested menus with a chevron indicator.
- Data attributes for styling: Every part exposes data-slot, items expose data-variant, and radix adds data-state and data-disabled, so any state is targetable from CSS.
Production tips
- Make the trigger the surface: ContextMenuTrigger is the right-click area itself, not a button. Wrap the card, row or canvas region you want the menu to belong to, and give it a size so there is something to click.
- Put danger items last: Separate destructive actions with a ContextMenuSeparator and keep them at the bottom with variant="danger", so muscle memory never lands on Delete.
- Match the on-page action: A context menu should offer the same actions the target already supports elsewhere. Do not hide the only path to an action behind a right-click, since it is not discoverable.
- Keep submenus shallow: One level of nesting is scannable; two is a maze. If a submenu grows past roughly seven items, promote it to a dialog or a dedicated panel.
- Mind checkbox item dismissal: By default the menu closes when a checkbox item is toggled. For multi-toggle panels, call event.preventDefault() in onSelect to keep it open.
Works with the rest of ai2
A context menu pairs naturally with a ai2 Table row or a ai2 Card as the right-click target, and ai2 Kbd caps inside shortcut hints. For a button-triggered version of the same menu vocabulary, reach for the ai2 Dropdown Menu.
A danger item usually leads to a confirmation: pair it with the ai2 Dialog for a hard confirm, or the ai2 Popover when you need arbitrary content rather than menu items. All of them share one token source and the same popover surface styling.