GitHub

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-menu
01

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

ComponentPropTypeDefaultDescription
ContextMenu.TriggerclassNamestringAdditional CSS classes
ContextMenuItemonSelect() => voidCalled when item is selected (menu closes)
disabledbooleanfalseDisable the item
insetbooleanfalseAdd left padding
classNamestringAdditional CSS classes
ContextMenuCheckboxItemcheckedbooleanfalseControlled checked state
defaultCheckedbooleanfalseUncontrolled initial state
onCheckedChange(checked: boolean) => voidCalled when state changes
disabledbooleanfalseDisable the item
ContextMenuRadioGroupvaluestringControlled value
defaultValuestring""Uncontrolled initial value
onValueChange(value: string) => voidCalled when value changes
ContextMenuRadioItemvaluestringUnique value for this radio item
disabledbooleanfalseDisable the item
ContextMenuSubdefaultOpenbooleanfalseStart with submenu open
openbooleanControlled open state
onOpenChange(open: boolean) => voidCalled when open state changes
React Principles