A right-click, long-press and Shift+F10 menu with submenus, checkbox and radio items, shortcut hints and a destructive style, built on Radix.
Q3 roadmap.pdf
Right-click, long-press or Shift+F10
"use client";
import { Copy, ExternalLink, FileText, Pencil, Star, Trash2 } from "lucide-react";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ballmac/context-menu";
export default function ContextMenuDemo() {
return (
<ContextMenu>
<ContextMenuTrigger
tabIndex={0}
aria-label="Q3 roadmap.pdf. Right-click or press Shift+F10 for actions"
className="flex w-full max-w-sm items-center gap-3 rounded-xl border bg-card p-4 shadow-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
<span className="flex size-11 items-center justify-center rounded-lg bg-muted">
<FileText aria-hidden="true" className="size-5" />
</span>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold">Q3 roadmap.pdf</p>
<p className="text-xs text-muted-foreground">
Right-click, long-press or Shift+F10
</p>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<ContextMenuItem>
<ExternalLink aria-hidden="true" /> Open
<ContextMenuShortcut>↵</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
<Pencil aria-hidden="true" /> Rename
<ContextMenuShortcut>F2</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<Copy aria-hidden="true" /> Copy as
</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Link</ContextMenuItem>
<ContextMenuItem>Markdown</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuItem>
<Star aria-hidden="true" /> Add to favorites
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem destructive>
<Trash2 aria-hidden="true" /> Move to trash
<ContextMenuShortcut>⌫</ContextMenuShortcut>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/context-menuInstall the dependencies.
$ pnpm add radix-ui lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/directionCopy the source into your project.
components/ballmac/context-menu.tsx// Ballmac UI: Context Menu. https://ui.ballmac.com/components/context-menu // Based on shadcn/ui Context Menu (MIT, Copyright (c) 2023 shadcn), restyled with tokens, destructive and inset items, and a shortcut slot. "use client"; import * as React from "react"; import { Check, ChevronRight, Circle } from "lucide-react"; import { ContextMenu as Primitive } from "radix-ui"; import { useDirection } from "@/lib/ballmac/direction"; import { cn } from "@/lib/utils"; const itemBase = "relative flex min-h-8 cursor-default select-none items-center gap-2 rounded-md 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 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground"; const surface = "z-50 min-w-[12rem] overflow-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-[0_12px_36px_-10px_rgb(0_0_0/0.25)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=open]:zoom-in-95 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 motion-reduce:animate-none"; type ContextMenuProps = React.ComponentProps<typeof Primitive.Root>; function ContextMenu(props: ContextMenuProps) { const dir = useDirection(props.dir); return <Primitive.Root data-slot="context-menu" dir={dir} {...props} />; } type ContextMenuTriggerProps = React.ComponentProps<typeof Primitive.Trigger>; function ContextMenuTrigger(props: ContextMenuTriggerProps) { return <Primitive.Trigger data-slot="context-menu-trigger" {...props} />; } type ContextMenuGroupProps = React.ComponentProps<typeof Primitive.Group>; function ContextMenuGroup(props: ContextMenuGroupProps) { return <Primitive.Group data-slot="context-menu-group" {...props} />; } type ContextMenuSubProps = React.ComponentProps<typeof Primitive.Sub>; function ContextMenuSub(props: ContextMenuSubProps) { return <Primitive.Sub data-slot="context-menu-sub" {...props} />; } type ContextMenuRadioGroupProps = React.ComponentProps< typeof Primitive.RadioGroup >; function ContextMenuRadioGroup(props: ContextMenuRadioGroupProps) { return ( <Primitive.RadioGroup data-slot="context-menu-radio-group" {...props} /> ); } type ContextMenuContentProps = React.ComponentProps<typeof Primitive.Content>; function ContextMenuContent({ className, collisionPadding = 12, ...props }: ContextMenuContentProps) { return ( <Primitive.Portal> <Primitive.Content data-slot="context-menu-content" collisionPadding={collisionPadding} className={cn( surface, "max-h-(--radix-context-menu-content-available-height) origin-(--radix-context-menu-content-transform-origin) overflow-y-auto", className, )} {...props} /> </Primitive.Portal> ); } type ContextMenuItemProps = React.ComponentProps<typeof Primitive.Item> & { /** Indent the item so it lines up with items that show a check or icon. */ inset?: boolean; /** Style the item as a destructive action (delete, sign out). */ destructive?: boolean; }; function ContextMenuItem({ className, inset, destructive, ...props }: ContextMenuItemProps) { return ( <Primitive.Item data-slot="context-menu-item" data-inset={inset ? "" : undefined} data-variant={destructive ? "destructive" : "default"} className={cn( itemBase, "data-[inset]:ps-8", "data-[variant=destructive]:text-destructive data-[variant=destructive]:data-[highlighted]:bg-destructive/10 data-[variant=destructive]:data-[highlighted]:text-destructive data-[variant=destructive]:[&_svg]:!text-destructive", className, )} {...props} /> ); } type ContextMenuCheckboxItemProps = React.ComponentProps< typeof Primitive.CheckboxItem >; function ContextMenuCheckboxItem({ className, children, ...props }: ContextMenuCheckboxItemProps) { return ( <Primitive.CheckboxItem data-slot="context-menu-checkbox-item" className={cn(itemBase, "ps-8", className)} {...props} > <span className="pointer-events-none absolute start-2 flex size-4 items-center justify-center"> <Primitive.ItemIndicator> <Check aria-hidden="true" className="size-4 !text-foreground" /> </Primitive.ItemIndicator> </span> {children} </Primitive.CheckboxItem> ); } type ContextMenuRadioItemProps = React.ComponentProps< typeof Primitive.RadioItem >; function ContextMenuRadioItem({ className, children, ...props }: ContextMenuRadioItemProps) { return ( <Primitive.RadioItem data-slot="context-menu-radio-item" className={cn(itemBase, "ps-8", className)} {...props} > <span className="pointer-events-none absolute start-2 flex size-4 items-center justify-center"> <Primitive.ItemIndicator> <Circle aria-hidden="true" className="size-2 fill-current !text-foreground" /> </Primitive.ItemIndicator> </span> {children} </Primitive.RadioItem> ); } type ContextMenuLabelProps = React.ComponentProps<typeof Primitive.Label> & { /** Indent the label to match inset items. */ inset?: boolean; }; function ContextMenuLabel({ className, inset, ...props }: ContextMenuLabelProps) { return ( <Primitive.Label data-slot="context-menu-label" data-inset={inset ? "" : undefined} className={cn( "px-2 py-1.5 text-xs font-medium text-muted-foreground data-[inset]:ps-8", className, )} {...props} /> ); } type ContextMenuSeparatorProps = React.ComponentProps< typeof Primitive.Separator >; function ContextMenuSeparator({ className, ...props }: ContextMenuSeparatorProps) { return ( <Primitive.Separator data-slot="context-menu-separator" className={cn("-mx-1 my-1 h-px bg-border", className)} {...props} /> ); } type ContextMenuShortcutProps = React.ComponentProps<"span">; /** Right-aligned keyboard hint. Decorative: the accessible name stays the item text, so add `aria-keyshortcuts` on the item when the shortcut is real. */ function ContextMenuShortcut({ className, ...props }: ContextMenuShortcutProps) { return ( <span data-slot="context-menu-shortcut" aria-hidden="true" className={cn( "ms-auto ps-4 font-mono text-xs tracking-wide text-muted-foreground", className, )} {...props} /> ); } type ContextMenuSubTriggerProps = React.ComponentProps< typeof Primitive.SubTrigger > & { /** Indent the trigger to match inset items. */ inset?: boolean; }; function ContextMenuSubTrigger({ className, inset, children, ...props }: ContextMenuSubTriggerProps) { return ( <Primitive.SubTrigger data-slot="context-menu-sub-trigger" data-inset={inset ? "" : undefined} className={cn( itemBase, "data-[inset]:ps-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground", className, )} {...props} > {children} <ChevronRight aria-hidden="true" className="ms-auto size-4 rtl:rotate-180" /> </Primitive.SubTrigger> ); } type ContextMenuSubContentProps = React.ComponentProps< typeof Primitive.SubContent >; function ContextMenuSubContent({ className, collisionPadding = 12, ...props }: ContextMenuSubContentProps) { return ( <Primitive.Portal> <Primitive.SubContent data-slot="context-menu-sub-content" collisionPadding={collisionPadding} className={cn( surface, "origin-(--radix-context-menu-content-transform-origin)", className, )} {...props} /> </Primitive.Portal> ); } export { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent, type ContextMenuProps, type ContextMenuTriggerProps, type ContextMenuContentProps, type ContextMenuGroupProps, type ContextMenuItemProps, type ContextMenuCheckboxItemProps, type ContextMenuRadioGroupProps, type ContextMenuRadioItemProps, type ContextMenuLabelProps, type ContextMenuSeparatorProps, type ContextMenuShortcutProps, type ContextMenuSubProps, type ContextMenuSubTriggerProps, type ContextMenuSubContentProps, };Update the import paths to match your project setup.
Usage
import { ContextMenu, ContextMenuTrigger, ContextMenuContent, ContextMenuGroup, ContextMenuItem, ContextMenuCheckboxItem, ContextMenuRadioGroup, ContextMenuRadioItem, ContextMenuLabel, ContextMenuSeparator, ContextMenuShortcut, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent } from "@/components/ballmac/context-menu"The full example is in the Code tab above.
Examples
View options
"use client";
import * as React from "react";
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ballmac/context-menu";
export default function ContextMenuStates() {
const [grid, setGrid] = React.useState(true);
const [zoom, setZoom] = React.useState("fit");
return (
<ContextMenu>
<ContextMenuTrigger
tabIndex={0}
aria-label="Canvas. Right-click or press Shift+F10 for view options"
className="grid h-40 w-full max-w-sm place-items-center rounded-xl border border-dashed bg-muted/40 text-sm text-muted-foreground outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{grid ? "Grid on" : "Grid off"} · zoom {zoom}
</ContextMenuTrigger>
<ContextMenuContent className="w-52">
<ContextMenuLabel>View</ContextMenuLabel>
<ContextMenuCheckboxItem checked={grid} onCheckedChange={setGrid}>
Show grid
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuRadioGroup value={zoom} onValueChange={setZoom}>
<ContextMenuRadioItem value="fit">Fit to screen</ContextMenuRadioItem>
<ContextMenuRadioItem value="100%">Actual size</ContextMenuRadioItem>
<ContextMenuRadioItem value="200%" disabled>
200% (unavailable)
</ContextMenuRadioItem>
</ContextMenuRadioGroup>
</ContextMenuContent>
</ContextMenu>
);
}API reference
<ContextMenuItem>
| Prop | Type | Default |
|---|---|---|
insetIndent the item so it lines up with items that show a check or icon. | boolean | — |
destructiveStyle the item as a destructive action (delete, sign out). | boolean | — |
<ContextMenuLabel>
| Prop | Type | Default |
|---|---|---|
insetIndent the label to match inset items. | boolean | — |
<ContextMenuSubTrigger>
| Prop | Type | Default |
|---|---|---|
insetIndent the trigger to match inset items. | boolean | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Right-click / long-press | Opens the menu at the pointer |
| Shift+F10 / Menu key | Opens it from a focused trigger |
| ArrowUp / ArrowDown | Moves between items |
| ArrowRight / ArrowLeft | Opens and closes a submenu |
| Escape | Closes the menu |
Use with AI
Shows actions at the pointer on right-click or long-press. Keyboard users open it with Shift+F10 or the Menu key on a focusable trigger. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Context Menu (@ballmac/context-menu) to this project with the shadcn MCP, then use it where it fits.
Use it for
- File, card and canvas actions
- Secondary actions that also exist elsewhere
- Desktop-style apps
Not for
- The only way to reach an action; menus must be discoverable, so also offer a dropdown-menu button
- Touch-first layouts without a visible trigger
Registry JSON: https://ui.ballmac.com/r/context-menu.json
Credits
Based on shadcn/ui Context Menu, adapted by Ballmac. Free to use in personal and commercial projects.
- Registry
- @ballmac/directionshadcn/utils
Pairs well with
Dropdown Menu
An action menu on a trigger with checkbox and radio items, submenus, shortcut hints and destructive rows, built on Radix for full keyboard and typeahead support.
Card
A composable content surface with compact spacing, an action slot, and optional interactive feedback.
Accordion
Vertically stacked disclosure sections on Radix Accordion, single or multiple open, with hairline dividers, a rotating plus and height animation.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.