Menus & FeedbackContext Menu
The dropdown's grammar, summoned at the pointer.
right click here
The thinking
A context menu and a dropdown are the same object asked for two ways, so I built them as one grammar wearing two anchors. The load-bearing decision is where the surface is born: a dropdown grows from its trigger's rect, but this one aims at
--radix-context-menu-content-transform-origin so the animate-pop-in starts exactly where the right-click landed — the menu answers from the spot the question was asked. To keep that equivalence honest I copied the item grammar in by hand instead of importing it — the same itemClasses, the same data-[highlighted]:bg-secondary-hover step — so this folder reads as a complete account of itself rather than a diff against its sibling. Duplication between siblings is cheaper than a dependency between them, and nothing here is novel for its own sake: the surface is deliberately indistinguishable from the dropdown, because the only thing that should change between them is the point in space they came from.Interface design
Every visual class matches the dropdown on purpose. The surface is
bg-popover with text-popover-foreground inside a border-border ring, rounded-lg, a p-1 gutter, and a min-w-[8rem] floor. Rows share one itemClasses string — px-2 py-1.5, text-sm, rounded-sm — so a plain item, a checkbox, and a radio read as one list; the stateful ones pad to pl-8 and park their indicator in an absolute left-2 span, a bold h-3.5 w-3.5 Check or an h-2 w-2 filled Circle, so state appears in space the text never occupied. Shortcuts sit ml-auto in font-mono text-xs text-muted-foreground — key combos are data, so they're set in the data face. The separator is -mx-1 my-1 h-px of bg-border, bleeding through the padding to the edges. The line is the surface splitting, not a rule drawn on it.Interaction
The entrance is
animate-pop-in — 0.2s of cubic-bezier(0.23, 0.88, 0.26, 0.92), opacity and scale from 0.96 — and the exit is animate-pop-out at 0.15s, because a dismissed surface should get out of the way faster than it arrived. What makes it a context menu is where that pop aims: origin-[--radix-context-menu-content-transform-origin] resolves to the pointer rather than a trigger rect, so the surface grows out of the pixel the right-click landed on. That anchor is the intent inference — a right-click is already a statement of place, so the menu comes to your attention instead of making your attention travel to wherever a button happens to sit. Radix reads the summons both ways, right-click on a mouse and long-press on touch, and once the menu is open, hover and arrow keys paint the identical data-[highlighted]:bg-secondary-hover step. An open sub-trigger holds data-[state=open]:bg-secondary-hover, so while you're in the submenu the row you came through stays lit.Sound design
This file ships zero audio. Sound is delegated to the global SoundLayer — one capturing click listener that plays the tap for anything carrying an interactive role, and Radix gives every row here one:
menuitem, menuitemcheckbox, and menuitemradio are all named in the layer's selector. The layer fires on click, not pointerdown, so the tap lands with the consequence rather than the gesture, and nothing here marks data-silent because choosing from a menu is a tap, not a paired state change. Disabled rows stay quiet twice over: data-[disabled]:pointer-events-none means the click never happens, and the layer skips aria-disabled targets anyway.Empathy
A disabled row keeps its seat in the list —
data-[disabled]:opacity-50 dims it and data-[disabled]:pointer-events-none makes it inert — so the demo's Forward item still documents that the action exists even when history doesn't. Keyboard users get the full Radix menu contract, arrow keys, typeahead, Escape to dismiss, and because pointer and focus both report through data-[highlighted], I set outline-none and let the bg-secondary-hover wash be the focus ring — one highlight for two input systems. Screen readers hear a menu of menuitems, with the checkbox row carrying aria-checked so it reads as state, not just a label. And motion-reduce:animate-none sits on both content and sub-content, so if you've asked the OS for less motion the menu simply appears and simply leaves. No pop is also an answer."use client";
import * as React from "react";
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { CaretRight, Check, Circle } from "@phosphor-icons/react/dist/ssr";
import { cn } from "@/lib/utils";
// the same surface as the dropdown, summoned differently — every visual
// class matches dropdown-menu on purpose, written here rather than
// imported so each folder stays a complete account of itself.
// sound is delegated: the menuitem* roles are the SoundLayer's contract.
const ContextMenu = ContextMenuPrimitive.Root;
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
const ContextMenuPortal = ContextMenuPrimitive.Portal;
const ContextMenuGroup = ContextMenuPrimitive.Group;
const ContextMenuSub = ContextMenuPrimitive.Sub;
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
const itemClasses =
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[highlighted]:bg-secondary-hover data-[disabled]:pointer-events-none data-[disabled]:opacity-50";
const ContextMenuContent = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Content>>;
}) => (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
ref={ref}
className={cn(
// anchored to the pointer, not a trigger rect — the origin var
// still aims the pop at where the right-click landed
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground origin-[--radix-context-menu-content-transform-origin] data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
);
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
const ContextMenuItem = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Item>>;
}) => (
<ContextMenuPrimitive.Item
ref={ref}
className={cn(itemClasses, className)}
{...props}
/>
);
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
const ContextMenuCheckboxItem = ({
ref,
className,
children,
checked,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.CheckboxItem>>;
}) => (
<ContextMenuPrimitive.CheckboxItem
ref={ref}
checked={checked}
className={cn(itemClasses, "pl-8", className)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5" weight="bold" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
ContextMenuCheckboxItem.displayName =
ContextMenuPrimitive.CheckboxItem.displayName;
const ContextMenuRadioItem = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.RadioItem>>;
}) => (
<ContextMenuPrimitive.RadioItem
ref={ref}
className={cn(itemClasses, "pl-8", className)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2" weight="fill" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
);
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
const ContextMenuLabel = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Label>>;
}) => (
<ContextMenuPrimitive.Label
ref={ref}
className={cn("px-2 py-1.5 text-sm font-medium", className)}
{...props}
/>
);
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
const ContextMenuSeparator = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.Separator>>;
}) => (
<ContextMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
const ContextMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn("ml-auto font-mono text-xs text-muted-foreground", className)}
{...props}
/>
);
ContextMenuShortcut.displayName = "ContextMenuShortcut";
const ContextMenuSubTrigger = ({
ref,
className,
children,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.SubTrigger>>;
}) => (
<ContextMenuPrimitive.SubTrigger
ref={ref}
className={cn(itemClasses, "data-[state=open]:bg-secondary-hover", className)}
{...props}
>
{children}
<CaretRight className="ml-auto h-3.5 w-3.5" />
</ContextMenuPrimitive.SubTrigger>
);
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
const ContextMenuSubContent = ({
ref,
className,
...props
}: React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent> & {
ref?: React.Ref<React.ComponentRef<typeof ContextMenuPrimitive.SubContent>>;
}) => (
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground origin-[--radix-context-menu-content-transform-origin] data-[state=open]:animate-pop-in data-[state=closed]:animate-pop-out motion-reduce:animate-none",
className
)}
{...props}
/>
);
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuPortal,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuSub,
ContextMenuSubTrigger,
ContextMenuSubContent,
ContextMenuGroup,
};