bigbullui logobigbullui
Componentnavigation

Context Menu

Right-click menu with smooth reveal, shortcuts, and dashed divider.

Preview

Live Preview
🎟️RIGHT-CLICK OR HOLD FOR TICKET OPTIONSContext menu opens with ticket stub styling

Installation

Tokens guide →

Add to your project via CLI (zero dependencies, code you own):

npx bigbullui add context-menu

Or install the full npm package:

npm install bigbullui

Usage

import { ContextMenu } from "@/components/ui/context-menu";

<ContextMenu
  items={[
    { id: "print", label: "Print Stub", shortcut: "⌘P" },
    "separator",
    { id: "revoke", label: "Revoke Ticket", danger: true },
  ]}
>
  <div className="p-8 border border-dashed rounded-lg">Right-click here</div>
</ContextMenu>

Source code

context-menu.tsx

Zero dependencies (only React + utils). Copy and paste directly into your project:

"use client";

import * as React from "react";
import { cn } from "./lib/utils";

export interface ContextMenuItem {
  id: string;
  label: string;
  shortcut?: string;
  danger?: boolean;
  disabled?: boolean;
  onSelect?: () => void;
}

export interface ContextMenuProps extends React.HTMLAttributes<HTMLDivElement> {
  items: (ContextMenuItem | "separator")[];
  children: React.ReactNode;
  className?: string;
}

export function ContextMenu({
  items,
  children,
  className,
  ...props
}: ContextMenuProps) {
  const [isOpen, setIsOpen] = React.useState(false);
  const [position, setPosition] = React.useState({ x: 0, y: 0 });
  const menuRef = React.useRef<HTMLDivElement>(null);

  const handleContextMenu = (e: React.MouseEvent) => {
    e.preventDefault();
    const x = Math.min(e.clientX, window.innerWidth - 200);
    const y = Math.min(e.clientY, window.innerHeight - 250);
    setPosition({ x, y });
    setIsOpen(true);
  };

  React.useEffect(() => {
    if (!isOpen) return;

    const handleClickOutside = (e: MouseEvent) => {
      if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
        setIsOpen(false);
      }
    };

    const handleKeyDown = (e: KeyboardEvent) => {
      if (e.key === "Escape") {
        setIsOpen(false);
      }
    };

    document.addEventListener("mousedown", handleClickOutside);
    document.addEventListener("keydown", handleKeyDown);
    return () => {
      document.removeEventListener("mousedown", handleClickOutside);
      document.removeEventListener("keydown", handleKeyDown);
    };
  }, [isOpen]);

  return (
    <div onContextMenu={handleContextMenu} className={cn("relative", className)} {...props}>
      {children}

      {isOpen && (
        <div
          ref={menuRef}
          role="menu"
          style={{
            position: "fixed",
            left: `${position.x}px`,
            top: `${position.y}px`,
          }}
          className="z-50 min-w-[180px] rounded-lg border-2 border-foreground bg-card p-1.5 shadow-xl outline-1 outline-dashed outline-offset-[-4px] animate-[scale-in_0.12s_ease-out_both] font-mono text-xs select-none"
        >
          {items.map((item, idx) => {
            if (item === "separator") {
              return (
                <div
                  key={`sep-${idx}`}
                  className="my-1 border-t border-dashed border-border"
                />
              );
            }

            return (
              <button
                key={item.id}
                type="button"
                disabled={item.disabled}
                onClick={() => {
                  item.onSelect?.();
                  setIsOpen(false);
                }}
                className={cn(
                  "flex w-full cursor-pointer items-center justify-between rounded-sm px-2.5 py-1.5 text-left text-xs transition-colors",
                  item.danger
                    ? "text-destructive hover:bg-destructive hover:text-white"
                    : "text-foreground hover:bg-accent hover:text-accent-foreground",
                  item.disabled && "opacity-40 pointer-events-none cursor-not-allowed"
                )}
              >
                <span>{item.label}</span>
                {item.shortcut && (
                  <span className="text-[10px] opacity-70 tracking-wider">
                    {item.shortcut}
                  </span>
                )}
              </button>
            );
          })}
        </div>
      )}
    </div>
  );
}

Props

PropTypeDescription
items(ContextMenuItem | 'separator')[]Context menu items array (required).
childrenReact.ReactNodeTarget trigger element (required).