Use Context Menu for commands associated with an object or region. A context menu must not be the only way to discover an essential action. Offer an ordinary button or menu for touch users and people unfamiliar with right-click behavior.
"use client";
import { Copy, ExternalLink, FolderInput, Pin, Trash2 } from "lucide-react";
import { useRef, useState } from "react";
import {
ContextMenu,
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
export default function ContextMenuDemo() {
const triggerRef = useRef<HTMLButtonElement>(null);
const [pinned, setPinned] = useState(true);
const [visibility, setVisibility] = useState("team");
return (
<ContextMenu>
<ContextMenuTrigger
render={
<button ref={triggerRef} type="button" aria-label="Open component card context menu" />
}
aria-haspopup="menu"
className="flex min-h-44 w-full max-w-sm flex-col items-start justify-between rounded-base border-2 border-border bg-secondary-background p-5 text-left shadow-shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<span className="rounded-base border-2 border-border bg-main px-2 py-1 text-xs font-heading uppercase tracking-wide text-main-foreground">
Component
</span>
<span>
<strong className="block text-xl font-heading">Dialog</strong>
<span className="mt-2 block text-sm leading-6 text-foreground/75">
Right-click or press Shift+F10 for registry actions.
</span>
</span>
</ContextMenuTrigger>
<ContextMenuContent className="w-64" finalFocus={triggerRef}>
<ContextMenuItem>
<ExternalLink aria-hidden="true" />
Open documentation
<ContextMenuShortcut>Enter</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuItem>
<Copy aria-hidden="true" />
Copy install command
<ContextMenuShortcut>Ctrl+C</ContextMenuShortcut>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>
<FolderInput aria-hidden="true" />
Move to collection
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem>Forms</ContextMenuItem>
<ContextMenuItem>Overlays</ContextMenuItem>
<ContextMenuItem>Navigation</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuCheckboxItem
checked={pinned}
onCheckedChange={(checked) => setPinned(checked === true)}
>
<Pin aria-hidden="true" />
Pin to workspace
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel inset>Visibility</ContextMenuLabel>
<ContextMenuRadioGroup value={visibility} onValueChange={setVisibility}>
<ContextMenuRadioItem value="private">Private</ContextMenuRadioItem>
<ContextMenuRadioItem value="team">Team</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">
<Trash2 aria-hidden="true" />
Remove from collection
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
#Installation
Complete the base setup. For manual installation:
First time? Install the shared base once.
npx shadcn@latest add https://neobrutal-ui.andongmin.com/r/context-menu.jsonnpm install @base-ui/react@^1.9.0Create components/ui/context-menu.tsx:
"use client";
import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu";
import { DirectionProvider } from "@base-ui/react/direction-provider";
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
import type * as React from "react";
import { cn } from "@/lib/utils";
type ContextMenuRootProps = ContextMenuPrimitive.Root.Props & {
dir?: "ltr" | "rtl";
};
type ContextMenuContentProps = ContextMenuPrimitive.Popup.Props &
Pick<
ContextMenuPrimitive.Positioner.Props,
| "align"
| "alignOffset"
| "arrowPadding"
| "collisionAvoidance"
| "collisionBoundary"
| "collisionPadding"
| "disableAnchorTracking"
| "positionMethod"
| "side"
| "sideOffset"
| "sticky"
>;
function ContextMenu({ dir, ...props }: ContextMenuRootProps) {
const menu = <ContextMenuPrimitive.Root {...props} />;
return dir ? <DirectionProvider direction={dir}>{menu}</DirectionProvider> : menu;
}
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"
{...props}
className={(state) =>
cn("select-none", typeof className === "function" ? className(state) : className)
}
/>
);
}
function ContextMenuContent({
align = "start",
alignOffset = 4,
arrowPadding = 0,
children,
className,
collisionAvoidance,
collisionBoundary,
collisionPadding = 0,
disableAnchorTracking,
positionMethod,
side = "right",
sideOffset = 0,
sticky,
...props
}: ContextMenuContentProps) {
return (
<ContextMenuPortal>
<ContextMenuPrimitive.Positioner
align={align}
alignOffset={alignOffset}
arrowPadding={arrowPadding}
collisionAvoidance={collisionAvoidance}
collisionBoundary={collisionBoundary}
collisionPadding={collisionPadding}
disableAnchorTracking={disableAnchorTracking}
positionMethod={positionMethod}
side={side}
sideOffset={sideOffset}
sticky={sticky}
className="isolate z-50 outline-none"
>
<ContextMenuPrimitive.Popup
data-slot="context-menu-content"
{...props}
className={(state) =>
cn(
"z-50 min-w-[8rem] max-h-(--available-height) origin-(--transform-origin) overflow-y-auto rounded-base border-2 border-border bg-main p-1 font-base text-main-foreground duration-100 outline-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
</ContextMenuPrimitive.Popup>
</ContextMenuPrimitive.Positioner>
</ContextMenuPortal>
);
}
function ContextMenuGroup(props: ContextMenuPrimitive.Group.Props) {
return <ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />;
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<"div"> & {
inset?: boolean;
}) {
return (
<div
data-slot="context-menu-label"
data-inset={inset}
role="presentation"
className={cn(
"border-2 border-transparent px-2 py-1.5 text-sm font-base text-main-foreground data-inset:pl-8",
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}
{...props}
className={(state) =>
cn(
"group/context-menu-item relative flex cursor-default items-center gap-2 rounded-base border-2 border-transparent bg-main px-2 py-1.5 text-sm font-base outline-none transition-colors select-none data-highlighted:outline-2 data-highlighted:outline-solid data-highlighted:-outline-offset-4 data-highlighted:outline-current data-inset:pl-8 data-[variant=destructive]:bg-destructive data-[variant=destructive]:text-destructive-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
function ContextMenuSub(props: ContextMenuPrimitive.SubmenuRoot.Props) {
return <ContextMenuPrimitive.SubmenuRoot data-slot="context-menu-sub" {...props} />;
}
function ContextMenuSubTrigger({
className,
children,
inset,
...props
}: ContextMenuPrimitive.SubmenuTrigger.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubmenuTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
{...props}
className={(state) =>
cn(
"flex cursor-default items-center gap-2 rounded-base border-2 border-transparent bg-main px-2 py-1.5 text-sm font-base outline-none select-none data-highlighted:outline-2 data-highlighted:outline-solid data-highlighted:-outline-offset-4 data-highlighted:outline-current data-inset:pl-8 data-popup-open:outline-2 data-popup-open:outline-solid data-popup-open:-outline-offset-4 data-popup-open:outline-current [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
typeof className === "function" ? className(state) : className,
)
}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubmenuTrigger>
);
}
function ContextMenuSubContent({ className, ...props }: ContextMenuContentProps) {
return (
<ContextMenuContent
data-slot="context-menu-sub-content"
side="right"
{...props}
className={(state) =>
cn("shadow-none", typeof className === "function" ? className(state) : className)
}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
closeOnClick = true,
inset,
...props
}: ContextMenuPrimitive.CheckboxItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
closeOnClick={closeOnClick}
{...props}
className={(state) =>
cn(
"relative flex cursor-default items-center gap-2 rounded-base border-2 border-transparent py-1.5 pr-2 pl-8 text-sm font-base text-main-foreground outline-none transition-colors select-none data-highlighted:outline-2 data-highlighted:outline-solid data-highlighted:-outline-offset-4 data-highlighted:outline-current data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
typeof className === "function" ? className(state) : className,
)
}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<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,
closeOnClick = true,
inset,
...props
}: ContextMenuPrimitive.RadioItem.Props & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
closeOnClick={closeOnClick}
{...props}
className={(state) =>
cn(
"relative flex cursor-default items-center gap-2 rounded-base border-2 border-transparent py-1.5 pr-2 pl-8 text-sm font-base text-main-foreground outline-none transition-colors select-none data-highlighted:outline-2 data-highlighted:outline-solid data-highlighted:-outline-offset-4 data-highlighted:outline-current data-inset:pl-8 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
typeof className === "function" ? className(state) : className,
)
}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.RadioItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.RadioItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuSeparator({ className, ...props }: ContextMenuPrimitive.Separator.Props) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
{...props}
className={(state) =>
cn(
"-mx-1 my-1 h-0.5 bg-border",
typeof className === "function" ? className(state) : className,
)
}
/>
);
}
function ContextMenuShortcut({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn("ml-auto text-xs font-base tracking-widest", className)}
{...props}
/>
);
}
export {
ContextMenu,
ContextMenuPortal,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuGroup,
ContextMenuLabel,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
};
#Usage
"use client";
import { useRef, useState } from "react";
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
} from "@/components/ui/context-menu";
export default function FileCommands() {
const triggerRef = useRef<HTMLDivElement>(null);
const [message, setMessage] = useState("No action selected.");
return (
<div className="grid gap-3">
<ContextMenu>
<ContextMenuTrigger
ref={triggerRef}
className="block rounded-base border-2 border-dashed border-border p-8"
tabIndex={0}
>
Right-click this file or press Shift+F10.
</ContextMenuTrigger>
<ContextMenuContent finalFocus={triggerRef}>
<ContextMenuItem onClick={() => setMessage("Duplicate selected in this preview.")}>
Duplicate
</ContextMenuItem>
<ContextMenuItem disabled>Move to archive</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
<p role="status">{message}</p>
</div>
);
}#API reference
| Prop / part | Purpose | Default |
|---|---|---|
ContextMenu | Root state and callbacks | Closed |
ContextMenuTrigger | Target region and render composition | Primitive trigger |
ContextMenuContent | Positioned menu surface | Primitive-managed |
ContextMenuContent.finalFocus | Element ref, function, or boolean controlling focus after close | Trigger or previously focused element |
ContextMenuItem.onClick / disabled | Action and availability | — / false |
ContextMenuCheckboxItem / ContextMenuRadioGroup | Stateful choices | Controlled or uncontrolled |
ContextMenuSub | Nested commands | — |
ContextMenuContent.side / align | Preferred popup placement and alignment | right / start |
ContextMenuContent.alignOffset / sideOffset | Alignment adjustment and trigger gap, in pixels | 4 / 0 |
ContextMenuContent.arrowPadding / collisionPadding | Arrow and collision-boundary spacing, in pixels | 0 |
ContextMenuItem.variant | Item appearance: default or destructive | default |
ContextMenuCheckboxItem.closeOnClick / ContextMenuRadioItem.closeOnClick | Close after selecting a stateful item | true |
#Accessibility
Make the relevant target keyboard reachable when it needs keyboard context-menu access. Test Shift+F10, arrow navigation, and Escape. Keep another discoverable route to essential commands. Avoid unexpectedly replacing the browser's context menu across the entire page.
Menu content is bounded by Base UI's available height. Long menus scroll so pointer users and keyboard users can reach every item.
Right-clicking does not focus the target in every browser. When closing the menu should return focus to its target, pass that target's ref to ContextMenuContent.finalFocus, as in the example. Base UI handles the focus return.