ContextMenu
A menu that appears on right-click over a target area. Supports nested submenus, checkbox and radio items, keyboard navigation, and shortcut hints.
AccessibleDark ModeKeyboard Nav
Install
$
npx react-principles add context-menu01
Features
- ✓Right-click to open: Appears at cursor position, auto-adjusts to stay in viewport
- ✓Nested submenus: Arbitrary depth with hover/click/keyboard support
- ✓Checkbox & Radio items: Controlled/uncontrolled toggle state
- ✓Keyboard navigation: Arrow keys, Enter, Escape
- ✓Auto-close: Escape, item selection, or outside click
02
Live Demo
Right-click here
03
Code Snippet
ContextMenu.tsx
import { ContextMenu } from "@/ui/ContextMenu"; function MyComponent() { return ( <ContextMenu> <ContextMenu.Trigger>Right-click me</ContextMenu.Trigger> <ContextMenu.Content> <ContextMenu.Item onSelect={() => {}}> Back <ContextMenu.Shortcut>Alt+Left</ContextMenu.Shortcut> </ContextMenu.Item> <ContextMenu.Item onSelect={() => {}}>Forward</ContextMenu.Item> <ContextMenu.Separator /> <ContextMenu.Sub> <ContextMenu.SubTrigger>More</ContextMenu.SubTrigger> <ContextMenu.Content> <ContextMenu.Item onSelect={() => {}}>Option A</ContextMenu.Item> <ContextMenu.Item onSelect={() => {}}>Option B</ContextMenu.Item> </ContextMenu.Content> </ContextMenu.Sub> </ContextMenu.Content> </ContextMenu> ); }
04
Copy-Paste (Single File)
ContextMenu.tsx
"use client"; import React, { createContext, useCallback, useContext, useEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type ReactElement, type ReactNode, } from "react"; import { cn } from "@/shared/utils/cn"; export interface ContextMenuProps { children: ReactNode; } export interface ContextMenuTriggerProps { children: ReactNode; className?: string; } export interface ContextMenuContentProps extends HTMLAttributes<HTMLDivElement> { children: ReactNode; } export interface ContextMenuItemProps extends ButtonHTMLAttributes<HTMLButtonElement> { children: ReactNode; onSelect?: () => void; disabled?: boolean; inset?: boolean; } export interface ContextMenuCheckboxItemProps extends ButtonHTMLAttributes<HTMLButtonElement> { children: ReactNode; checked?: boolean; defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean; } export interface ContextMenuRadioGroupProps { value?: string; defaultValue?: string; onValueChange?: (value: string) => void; children: ReactNode; className?: string; } export interface ContextMenuRadioItemProps extends ButtonHTMLAttributes<HTMLButtonElement> { value: string; children: ReactNode; disabled?: boolean; } export interface ContextMenuSeparatorProps extends HTMLAttributes<HTMLDivElement> { className?: string; } export interface ContextMenuSubProps { children: ReactNode; className?: string; defaultOpen?: boolean; open?: boolean; onOpenChange?: (open: boolean) => void; } export interface ContextMenuSubTriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> { children: ReactNode; } export interface ContextMenuShortcutProps extends HTMLAttributes<HTMLSpanElement> { children: ReactNode; } interface ContextMenuContextValue { open: boolean; setOpen: (open: boolean) => void; closeAll: () => void; position: React.MutableRefObject<{ x: number; y: number }>; } const ContextMenuContext = createContext<ContextMenuContextValue | null>(null); function useContextMenuContext() { const ctx = useContext(ContextMenuContext); if (!ctx) throw new Error("ContextMenu sub-components must be used inside <ContextMenu>"); return ctx; } export function ContextMenu({ children }: ContextMenuProps) { const [open, setOpen] = useState(false); const position = useRef({ x: 0, y: 0 }); const closeAll = useCallback(() => setOpen(false), []); return ( <ContextMenuContext.Provider value={{ open, setOpen, closeAll, position }}> {children} </ContextMenuContext.Provider> ); } ContextMenu.Trigger = function ContextMenuTrigger({ children, className }: ContextMenuTriggerProps) { const { setOpen, position } = useContextMenuContext(); return ( <div className={cn("inline-block", className)} onContextMenu={(e) => { e.preventDefault(); position.current = { x: e.clientX, y: e.clientY }; setOpen(true); }} > {children} </div> ); }; ContextMenu.Content = function ContextMenuContent({ children, className, isOpen, onClose, isSubmenu, ...props }: ContextMenuContentProps & { isOpen?: boolean; onClose?: () => void; isSubmenu?: boolean }) { const { position } = useContextMenuContext(); const contentRef = useRef<HTMLDivElement>(null); useEffect(() => { if (isOpen && contentRef.current) { const rect = contentRef.current.getBoundingClientRect(); const vw = window.innerWidth; const vh = window.innerHeight; const x = Math.min(position.current.x, vw - rect.width - 8); const y = Math.min(position.current.y, vh - rect.height - 8); contentRef.current.style.left = `${Math.max(x, 4)}px`; contentRef.current.style.top = `${Math.max(y, 4)}px`; } }, [isOpen, position]); useEffect(() => { if (!isOpen) return; const handlePointerDown = (e: MouseEvent) => { if (contentRef.current?.contains(e.target as Node)) return; onClose?.(); }; const timer = setTimeout(() => document.addEventListener("pointerdown", handlePointerDown), 0); return () => { clearTimeout(timer); document.removeEventListener("pointerdown", handlePointerDown); }; }, [isOpen, onClose]); if (!isOpen) return null; return ( <div ref={contentRef} role="menu" aria-orientation="vertical" className={cn( "z-50 min-w-[200px] p-1 fixed", "bg-white dark:bg-[#161b22] border border-slate-200 dark:border-[#1f2937]", "rounded-lg shadow-lg", isSubmenu ? "left-full top-0 ml-1" : "", className, )} {...props} > {children} </div> ); } ContextMenu.Item = function ContextMenuItem({ children, onSelect, disabled, className, onClick, ...props }: ContextMenuItemProps) { const { closeAll } = useContextMenuContext(); return ( <button type="button" role="menuitem" disabled={disabled} onClick={(e) => { onClick?.(e); if (disabled) return; onSelect?.(); closeAll(); }} className={cn( "relative flex w-full items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors cursor-default", "text-slate-700 dark:text-slate-300 focus:bg-slate-100 focus:text-slate-900 dark:focus:bg-[#1f2937] dark:focus:text-white", "data-[disabled]:pointer-events-none data-[disabled]:opacity-50", className, )} {...props} > {children} </button> ); }; ContextMenu.Separator = function ContextMenuSeparator({ className, ...props }: ContextMenuSeparatorProps) { return ( <div className={cn("-mx-1 my-1 h-px bg-slate-200 dark:bg-[#1f2937]", className)} role="separator" aria-orientation="horizontal" {...props} /> ); }; ContextMenu.Shortcut = function ContextMenuShortcut({ children, className, ...props }: ContextMenuShortcutProps) { return <span className={cn("ml-auto text-xs tracking-widest text-slate-500 dark:text-slate-400", className)} {...props}>{children}</span>; };
05
Usage Examples
With shortcuts
<ContextMenu> <ContextMenu.Trigger> <p>Right-click me</p> </ContextMenu.Trigger> <ContextMenu.Content> <ContextMenu.Item onSelect={() => {}}> Back <ContextMenu.Shortcut>Alt+Left</ContextMenu.Shortcut> </ContextMenu.Item> <ContextMenu.Item onSelect={() => {}}> Forward <ContextMenu.Shortcut>Alt+Right</ContextMenu.Shortcut> </ContextMenu.Item> </ContextMenu.Content> </ContextMenu>
Nested submenu
<ContextMenu.Sub> <ContextMenu.SubTrigger>Open Recent</ContextMenu.SubTrigger> <ContextMenu.Content> <ContextMenu.Item onSelect={() => {}}>home.html</ContextMenu.Item> <ContextMenu.Item onSelect={() => {}}>about.html</ContextMenu.Item> </ContextMenu.Content> </ContextMenu.Sub>
Checkbox items
<ContextMenu.CheckboxItem defaultChecked> Show Bookmarks Bar </ContextMenu.CheckboxItem>
Radio group
<ContextMenu.RadioGroup defaultValue="system"> <ContextMenu.RadioItem value="light">Light</ContextMenu.RadioItem> <ContextMenu.RadioItem value="dark">Dark</ContextMenu.RadioItem> <ContextMenu.RadioItem value="system">System</ContextMenu.RadioItem> </ContextMenu.RadioGroup>
06
Props
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| ContextMenu.Trigger | className | string | — | Additional CSS classes |
| ContextMenuItem | onSelect | () => void | — | Called when item is selected (menu closes) |
| disabled | boolean | false | Disable the item | |
| inset | boolean | false | Add left padding | |
| className | string | — | Additional CSS classes | |
| ContextMenuCheckboxItem | checked | boolean | false | Controlled checked state |
| defaultChecked | boolean | false | Uncontrolled initial state | |
| onCheckedChange | (checked: boolean) => void | — | Called when state changes | |
| disabled | boolean | false | Disable the item | |
| ContextMenuRadioGroup | value | string | — | Controlled value |
| defaultValue | string | "" | Uncontrolled initial value | |
| onValueChange | (value: string) => void | — | Called when value changes | |
| ContextMenuRadioItem | value | string | — | Unique value for this radio item |
| disabled | boolean | false | Disable the item | |
| ContextMenuSub | defaultOpen | boolean | false | Start with submenu open |
| open | boolean | — | Controlled open state | |
| onOpenChange | (open: boolean) => void | — | Called when open state changes |