Right-click menu sharing the dropdown-menu visual language: origin-aware physics, checkbox and radio items, submenus, kbd shortcuts.
npx shadcn@latest add @paragon/context-menu"use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
/*
* Same visual and motion language as dropdown-menu: origin-aware
* 0.97 -> 1 scale over 200ms ease-out, 150ms non-retracing exit,
* submenus sliding 4px from their trigger side. The origin var makes
* the menu grow from the pointer position.
*/
const contextMenuStyles = `
@keyframes pg-ctx-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-ctx-out { to { opacity: 0; scale: 0.99; } }
@keyframes pg-ctx-sub-in-right { from { opacity: 0; translate: -4px 0; } }
@keyframes pg-ctx-sub-in-left { from { opacity: 0; translate: 4px 0; } }
@keyframes pg-ctx-sub-out { to { opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-ctx-in { from { opacity: 0; } }
@keyframes pg-ctx-out { to { opacity: 0; } }
@keyframes pg-ctx-sub-in-right { from { opacity: 0; } }
@keyframes pg-ctx-sub-in-left { from { opacity: 0; } }
}
`;
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 ContextMenuPortal(
props: React.ComponentProps<typeof ContextMenuPrimitive.Portal>,
) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
);
}
function ContextMenuGroup(
props: React.ComponentProps<typeof ContextMenuPrimitive.Group>,
) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
);
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<>
{/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
child node, and the Radix menu Portal enforces a single child. */}
<style href="paragon-context-menu" precedence="paragon">{contextMenuStyles}</style>
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"z-50 min-w-40 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay",
"data-[state=open]:animate-[pg-ctx-in_200ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-ctx-out_150ms_var(--ease-exit)_forwards]",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
</>
);
}
const contextMenuItemClasses = cn(
"relative flex cursor-default items-center gap-2 rounded-md px-2 py-1.5 text-sm outline-none select-none",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
);
export interface ContextMenuItemProps
extends React.ComponentProps<typeof ContextMenuPrimitive.Item> {
/** Indent to align with items that carry a leading indicator. */
inset?: boolean;
variant?: "default" | "destructive";
}
function ContextMenuItem({
className,
inset = false,
variant = "default",
...props
}: ContextMenuItemProps) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-variant={variant}
className={cn(
contextMenuItemClasses,
inset && "pl-8",
variant === "destructive" &&
"text-destructive data-[highlighted]:bg-destructive/10 data-[highlighted]:text-destructive [&_svg]:text-destructive",
className,
)}
{...props}
/>
);
}
function ContextMenuCheckboxItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(contextMenuItemClasses, "pl-8", className)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}
function ContextMenuRadioGroup(
props: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>,
) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
);
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(contextMenuItemClasses, "pl-8", className)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-4 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="size-1.5 fill-current text-foreground" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
}
function ContextMenuLabel({
className,
inset = false,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
className={cn(
"px-2 py-1.5 text-xs font-medium text-muted-foreground",
inset && "pl-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<"kbd">) {
return (
<kbd
data-slot="context-menu-shortcut"
className={cn(
"ml-auto font-sans text-xs tracking-widest text-muted-foreground",
className,
)}
{...props}
/>
);
}
function ContextMenuSub(
props: React.ComponentProps<typeof ContextMenuPrimitive.Sub>,
) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}
function ContextMenuSubTrigger({
className,
inset = false,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean;
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
className={cn(
contextMenuItemClasses,
"data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"z-50 min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-overlay",
"data-[state=open]:data-[side=right]:animate-[pg-ctx-sub-in-right_150ms_var(--ease-out)]",
"data-[state=open]:data-[side=left]:animate-[pg-ctx-sub-in-left_150ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-ctx-sub-out_100ms_var(--ease-exit)_forwards]",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuPortal,
ContextMenuContent,
ContextMenuGroup,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
};