Context Menu
Right-click (desktop) or long-press (mobile) contextual action panel. Glass-backed floating surface with support for grouped actions, keyboard shortcuts, dividers, and destructive items.
Preview
Right-click anywhere in this zone
Anatomy
| Element | Spec |
|---|---|
| Panel surface | blur(20px) saturate(160%), --surface at 92% opacity, --shadow-sheet, 1px border |
| Specular top edge | 1px gradient line: transparent → white/15% → transparent |
| Action item | 13px, py-1.5, px-3, hover: --surface-raised, optional icon (16px, 60% opacity) + shortcut kbd |
| Destructive item | --status-danger text, hover: --status-danger/10% background |
| Separator | 1px --border-subtle, my-1 vertical margin, role='separator' |
| Min width | 180px — expands to fit content |
| Border radius | --radius-md (10px) |
Implementation
ContextMenu.tsx
tsx
"use client";
import { useState, useRef, useEffect, useCallback } from "react";
import { cn } from "@/lib";
export interface ContextMenuAction {
type?: "action";
label: string;
icon?: React.ReactNode;
shortcut?: string;
destructive?: boolean;
disabled?: boolean;
onSelect: () => void;
}
export interface ContextMenuSeparator { type: "separator" }
export type ContextMenuItem = ContextMenuAction | ContextMenuSeparator;
interface ContextMenuProps {
items: ContextMenuItem[];
children: React.ReactNode;
onOpenChange?: (open: boolean) => void;
}
export function ContextMenu({ items, children, onOpenChange }: ContextMenuProps) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const open = (x: number, y: number) => {
setPos({ x, y });
onOpenChange?.(true);
};
const close = useCallback(() => {
setPos(null);
onOpenChange?.(false);
}, [onOpenChange]);
useEffect(() => {
if (!pos) return;
const handler = (e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
close();
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", handler);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", handler);
};
}, [pos, close]);
return (
<>
<div
onContextMenu={(e) => {
e.preventDefault();
open(e.clientX, e.clientY);
}}
>
{children}
</div>
{pos && (
<div
ref={menuRef}
role="menu"
className="fixed z-50 min-w-[180px] py-1 rounded-[var(--radius-md)] border border-[rgb(var(--border))]"
style={{
top: pos.y,
left: pos.x,
background: "rgb(var(--surface) / 0.92)",
backdropFilter: "blur(20px) saturate(160%)",
WebkitBackdropFilter: "blur(20px) saturate(160%)",
boxShadow: "var(--shadow-sheet), inset 0 1px 0 rgba(255,255,255,0.08)",
}}
>
{/* Specular top-edge */}
<div className="absolute inset-x-0 top-0 h-px rounded-t-[var(--radius-md)] bg-gradient-to-r from-transparent via-white/15 to-transparent pointer-events-none" />
{items.map((item, i) => {
if (item.type === "separator") {
return <div key={i} className="my-1 h-px bg-[rgb(var(--border-subtle))]" role="separator" />;
}
const action = item as ContextMenuAction;
return (
<button
key={i}
role="menuitem"
disabled={action.disabled}
onClick={() => { action.onSelect(); close(); }}
className={cn(
"w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-left transition-colors duration-100",
action.destructive
? "text-[rgb(var(--status-danger))] hover:bg-[rgb(var(--status-danger)/0.1)]"
: "text-[rgb(var(--text-primary))] hover:bg-[rgb(var(--surface-raised))]",
action.disabled && "opacity-40 cursor-not-allowed pointer-events-none"
)}
>
{action.icon && <span className="w-4 h-4 shrink-0 opacity-60">{action.icon}</span>}
<span className="flex-1">{action.label}</span>
{action.shortcut && (
<kbd className="font-mono text-[10px] text-[rgb(var(--text-tertiary))] ml-4">{action.shortcut}</kbd>
)}
</button>
);
})}
</div>
)}
</>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
items | ContextMenuItem[] | — | Array of menu items. Use { type: 'separator' } to insert a divider between groups. |
children | ReactNode | — | The element that receives the right-click / long-press trigger. |
onOpenChange | (open: boolean) => void | — | Called when the menu opens or closes. |
Accessibility
- →Set role='menu' on the panel and role='menuitem' on each action button.
- →Escape always closes the menu and returns focus to the trigger element.
- →Arrow keys navigate between items; Enter / Space activates the focused item.
- →Long-press triggers the menu on touch screens — set a 500ms delay matching OS convention.
- →Destructive items should be placed last and separated by a divider — never first.
- →Disabled items remain focusable (aria-disabled='true') but do not execute their action.