abnormal.UI overview

Overlays

Context Menu

A right-click menu.

Preview

Right-click here

Install

pnpm dlx shadcn@latest add @abnormal/context-menu
Brings inutilslucide-reactradix-ui

Usage

import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";

export default function ContextMenuDemo() {
return (
<ContextMenu>
<ContextMenuTrigger className="grid h-32 w-full max-w-sm place-items-center rounded-panel border border-dashed border-border text-muted-foreground">
Right-click here
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuLabel>Message</ContextMenuLabel>
<ContextMenuItem>Reply</ContextMenuItem>
<ContextMenuItem>Forward</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem variant="destructive">Delete</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
Source
"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import { ContextMenu as ContextMenuPrimitive } from "radix-ui";
import { ChevronRightIcon } from "lucide-react";

/** Context menu: same look as DropdownMenu (a separate paper-like popover, menu items with the control radius), just popping up at the pointer. */
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 ContextMenuContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-44 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-mark border border-border bg-popover p-1.5 text-popover-foreground shadow-popover data-open:animate-arrive data-closed:animate-leave data-[state=closed]:overflow-hidden",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}

const itemClassName =
"relative flex cursor-default items-center gap-2 rounded-control px-2.5 py-2 text-base outline-hidden select-none 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-3.5";

function ContextMenuItem({
className,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
variant?: "default" | "destructive";
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-variant={variant}
className={cn(
itemClassName,
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive",
className,
)}
{...props}
/>
);
}

function ContextMenuLabel({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Label>) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
className={cn("px-2 py-1 text-xs font-medium text-muted-foreground", 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 ContextMenuSub({ ...props }: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
}

function ContextMenuSubTrigger({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger>) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
className={cn(itemClassName, "data-open:bg-accent data-open:text-accent-foreground", className)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
);
}

function ContextMenuSubContent({ className, ...props }: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
// Same reasoning as DropdownMenuSubContent: render through a Portal, so it is not clipped by the parent menu's overflow-x-hidden.
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"z-50 max-h-(--radix-context-menu-content-available-height) min-w-40 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-mark border border-border bg-popover p-1.5 text-popover-foreground shadow-popover data-open:animate-arrive data-closed:animate-leave",
className,
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
}

export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
};