Context Menu
Displays a menu to the user — such as a set of actions or functions.
The Context Menu component displays a menu when the user right-clicks on a trigger element. It supports keyboard shortcuts, separators, checkboxes, submenus, and disabled items. Built from scratch using React and native HTML elements. No dependencies on any UI library.
Code
TypeScript: Copy this code into components/ui/context-menu.tsx:
tsx
"use client"
import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
interface ContextMenuContextValue {
open: boolean
setOpen: (open: boolean) => void
position: { x: number; y: number } | null
setPosition: (position: { x: number; y: number } | null) => void
selectedIndex: number
setSelectedIndex: (index: number) => void
}
const ContextMenuContext = React.createContext<ContextMenuContextValue | undefined>(undefined)
const useContextMenu = () => {
const context = React.useContext(ContextMenuContext)
if (!context) {
throw new Error("ContextMenu components must be used within ContextMenu")
}
return context
}
export interface ContextMenuItem {
id: string
label: string
shortcut?: string
disabled?: boolean
checked?: boolean
onSelect?: () => void
icon?: React.ReactNode
separator?: boolean
submenu?: ContextMenuItem[]
}
interface ContextMenuProps {
open?: boolean
onOpenChange?: (open: boolean) => void
children: React.ReactNode
className?: string
}
const ContextMenu = React.forwardRef<HTMLDivElement, ContextMenuProps>(
({ open: controlledOpen, onOpenChange, children, className, ...props }, ref) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const [position, setPosition] = React.useState<{ x: number; y: number } | null>(null)
const [selectedIndex, setSelectedIndex] = React.useState(-1)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const handleOpenChange = React.useCallback((newOpen: boolean) => {
if (!isControlled) {
setUncontrolledOpen(newOpen)
}
onOpenChange?.(newOpen)
if (!newOpen) {
setPosition(null)
setSelectedIndex(-1)
}
}, [isControlled, onOpenChange])
React.useEffect(() => {
if (!open) return
const handleClick = () => handleOpenChange(false)
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handleOpenChange(false)
}
}
document.addEventListener("click", handleClick, true)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("click", handleClick, true)
document.removeEventListener("keydown", handleEscape)
}
}, [open, handleOpenChange])
return (
<ContextMenuContext.Provider value={{
open,
setOpen: handleOpenChange,
position,
setPosition,
selectedIndex,
setSelectedIndex,
}}>
<div ref={ref} className={cn("relative", className)} {...props}>
{children}
</div>
</ContextMenuContext.Provider>
)
}
)
ContextMenu.displayName = "ContextMenu"
interface ContextMenuTriggerProps extends React.HTMLAttributes<HTMLDivElement> {}
const ContextMenuTrigger = React.forwardRef<HTMLDivElement, ContextMenuTriggerProps>(
({ className, children, ...props }, ref) => {
const { setOpen, setPosition } = useContextMenu()
const handleContextMenu = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault()
e.stopPropagation()
setPosition({ x: e.clientX, y: e.clientY })
setOpen(true)
}
return (
<div
ref={ref}
onContextMenu={handleContextMenu}
className={cn("cursor-context-menu", className)}
{...props}
>
{children}
</div>
)
}
)
ContextMenuTrigger.displayName = "ContextMenuTrigger"
interface ContextMenuContentProps extends React.HTMLAttributes<HTMLDivElement> {
items: ContextMenuItem[]
}
const ContextMenuContent = React.forwardRef<HTMLDivElement, ContextMenuContentProps>(
({ className, items, ...props }, ref) => {
const { open, position, setOpen } = useContextMenu()
const [hoveredSubmenu, setHoveredSubmenu] = React.useState<string | null>(null)
const menuRef = React.useRef<HTMLDivElement>(null)
const submenuRefs = React.useRef<Record<string, HTMLDivElement | null>>({})
React.useImperativeHandle(ref, () => menuRef.current as HTMLDivElement)
React.useEffect(() => {
if (!open || !position || !menuRef.current) return
const menu = menuRef.current
const rect = menu.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = position.x
let y = position.y
if (x + rect.width > viewportWidth) {
x = viewportWidth - rect.width - 8
}
if (y + rect.height > viewportHeight) {
y = viewportHeight - rect.height - 8
}
if (x < 8) x = 8
if (y < 8) y = 8
menu.style.left = `${x}px`
menu.style.top = `${y}px`
}, [open, position])
React.useEffect(() => {
if (!hoveredSubmenu || !submenuRefs.current[hoveredSubmenu]) return
const submenu = submenuRefs.current[hoveredSubmenu]
if (!submenu) return
const rect = submenu.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = position?.x || 0
const menuWidth = menuRef.current?.offsetWidth || 0
x = x + menuWidth + 4
if (x + rect.width > viewportWidth) {
x = (position?.x || 0) - rect.width - 4
}
if (x < 8) x = 8
submenu.style.left = `${x}px`
submenu.style.top = `${position?.y || 0}px`
}, [hoveredSubmenu, position])
if (!open || !position) return null
const renderItem = (item: ContextMenuItem, index: number) => {
if (item.separator) {
return (
<div
key={item.id}
className="h-px bg-foreground my-1 mx-2"
/>
)
}
const isDisabled = item.disabled
const isChecked = item.checked
const hasSubmenu = item.submenu && item.submenu.length > 0
const isHovered = hoveredSubmenu === item.id
return (
<div key={item.id} className="relative">
<button
type="button"
disabled={isDisabled}
onClick={() => {
if (!isDisabled && !hasSubmenu) {
item.onSelect?.()
setOpen(false)
}
}}
onMouseEnter={() => {
if (!isDisabled) {
if (hasSubmenu) {
setHoveredSubmenu(item.id)
} else {
setHoveredSubmenu(null)
}
}
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm font-bold transition-colors text-left",
isDisabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-accent hover:text-accent-foreground cursor-pointer",
className
)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isChecked && (
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</span>
)}
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
<span className="flex-1 truncate">{item.label}</span>
</div>
<div className="flex items-center gap-4 ml-4">
{item.shortcut && (
<span className="text-xs text-muted-foreground font-normal">
{item.shortcut}
</span>
)}
{hasSubmenu && (
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</div>
</button>
{hasSubmenu && isHovered && item.submenu && (
<div
ref={(el) => {
submenuRefs.current[item.id] = el
}}
className="fixed z-50 min-w-[200px] rounded-md border-2 border-foreground bg-background neobrutalism-shadow p-1"
>
{item.submenu.map((subItem, subIndex) => renderItem(subItem, subIndex))}
</div>
)}
</div>
)
}
const content = (
<div
ref={menuRef}
className={cn(
"fixed z-50 min-w-[200px] rounded-md border-2 border-foreground bg-background neobrutalism-shadow p-1",
className
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
{items.map((item, index) => renderItem(item, index))}
</div>
)
return ReactDOM.createPortal(content, document.body)
}
)
ContextMenuContent.displayName = "ContextMenuContent"
export { ContextMenu, ContextMenuTrigger, ContextMenuContent }JavaScript: Copy this code into components/ui/context-menu.jsx:
jsx
"use client"
import * as React from "react"
import * as ReactDOM from "react-dom"
import { cn } from "@/lib/utils"
const ContextMenuContext = React.createContext(undefined)
const useContextMenu = () => {
const context = React.useContext(ContextMenuContext)
if (!context) {
throw new Error("ContextMenu components must be used within ContextMenu")
}
return context
}
const ContextMenu = React.forwardRef(
({ open: controlledOpen, onOpenChange, children, className, ...props }, ref) => {
const [uncontrolledOpen, setUncontrolledOpen] = React.useState(false)
const [position, setPosition] = React.useState(null)
const [selectedIndex, setSelectedIndex] = React.useState(-1)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const handleOpenChange = React.useCallback((newOpen) => {
if (!isControlled) {
setUncontrolledOpen(newOpen)
}
onOpenChange?.(newOpen)
if (!newOpen) {
setPosition(null)
setSelectedIndex(-1)
}
}, [isControlled, onOpenChange])
React.useEffect(() => {
if (!open) return
const handleClick = () => handleOpenChange(false)
const handleEscape = (e) => {
if (e.key === "Escape") {
handleOpenChange(false)
}
}
document.addEventListener("click", handleClick, true)
document.addEventListener("keydown", handleEscape)
return () => {
document.removeEventListener("click", handleClick, true)
document.removeEventListener("keydown", handleEscape)
}
}, [open, handleOpenChange])
return (
<ContextMenuContext.Provider value={{
open,
setOpen: handleOpenChange,
position,
setPosition,
selectedIndex,
setSelectedIndex,
}}>
<div ref={ref} className={cn("relative", className)} {...props}>
{children}
</div>
</ContextMenuContext.Provider>
)
}
)
ContextMenu.displayName = "ContextMenu"
const ContextMenuTrigger = React.forwardRef(
({ className, children, ...props }, ref) => {
const { setOpen, setPosition } = useContextMenu()
const handleContextMenu = (e) => {
e.preventDefault()
e.stopPropagation()
setPosition({ x: e.clientX, y: e.clientY })
setOpen(true)
}
return (
<div
ref={ref}
onContextMenu={handleContextMenu}
className={cn("cursor-context-menu", className)}
{...props}
>
{children}
</div>
)
}
)
ContextMenuTrigger.displayName = "ContextMenuTrigger"
const ContextMenuContent = React.forwardRef(
({ className, items, ...props }, ref) => {
const { open, position, setOpen } = useContextMenu()
const [hoveredSubmenu, setHoveredSubmenu] = React.useState(null)
const menuRef = React.useRef(null)
const submenuRefs = React.useRef({})
React.useImperativeHandle(ref, () => menuRef.current)
React.useEffect(() => {
if (!open || !position || !menuRef.current) return
const menu = menuRef.current
const rect = menu.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = position.x
let y = position.y
if (x + rect.width > viewportWidth) {
x = viewportWidth - rect.width - 8
}
if (y + rect.height > viewportHeight) {
y = viewportHeight - rect.height - 8
}
if (x < 8) x = 8
if (y < 8) y = 8
menu.style.left = `${x}px`
menu.style.top = `${y}px`
}, [open, position])
React.useEffect(() => {
if (!hoveredSubmenu || !submenuRefs.current[hoveredSubmenu]) return
const submenu = submenuRefs.current[hoveredSubmenu]
if (!submenu) return
const rect = submenu.getBoundingClientRect()
const viewportWidth = window.innerWidth
const viewportHeight = window.innerHeight
let x = position?.x || 0
const menuWidth = menuRef.current?.offsetWidth || 0
x = x + menuWidth + 4
if (x + rect.width > viewportWidth) {
x = (position?.x || 0) - rect.width - 4
}
if (x < 8) x = 8
submenu.style.left = `${x}px`
submenu.style.top = `${position?.y || 0}px`
}, [hoveredSubmenu, position])
if (!open || !position) return null
const renderItem = (item, index) => {
if (item.separator) {
return (
<div
key={item.id}
className="h-px bg-foreground my-1 mx-2"
/>
)
}
const isDisabled = item.disabled
const isChecked = item.checked
const hasSubmenu = item.submenu && item.submenu.length > 0
const isHovered = hoveredSubmenu === item.id
return (
<div key={item.id} className="relative">
<button
type="button"
disabled={isDisabled}
onClick={() => {
if (!isDisabled && !hasSubmenu) {
item.onSelect?.()
setOpen(false)
}
}}
onMouseEnter={() => {
if (!isDisabled) {
if (hasSubmenu) {
setHoveredSubmenu(item.id)
} else {
setHoveredSubmenu(null)
}
}
}}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm font-bold transition-colors text-left",
isDisabled
? "opacity-50 cursor-not-allowed"
: "hover:bg-accent hover:text-accent-foreground cursor-pointer",
className
)}
>
<div className="flex items-center gap-2 flex-1 min-w-0">
{isChecked && (
<span className="flex-shrink-0 w-4 h-4 flex items-center justify-center">
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</span>
)}
{item.icon && <span className="flex-shrink-0">{item.icon}</span>}
<span className="flex-1 truncate">{item.label}</span>
</div>
<div className="flex items-center gap-4 ml-4">
{item.shortcut && (
<span className="text-xs text-muted-foreground font-normal">
{item.shortcut}
</span>
)}
{hasSubmenu && (
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
)}
</div>
</button>
{hasSubmenu && isHovered && item.submenu && (
<div
ref={(el) => {
submenuRefs.current[item.id] = el
}}
className="fixed z-50 min-w-[200px] rounded-md border-2 border-foreground bg-background neobrutalism-shadow p-1"
>
{item.submenu.map((subItem, subIndex) => renderItem(subItem, subIndex))}
</div>
)}
</div>
)
}
const content = (
<div
ref={menuRef}
className={cn(
"fixed z-50 min-w-[200px] rounded-md border-2 border-foreground bg-background neobrutalism-shadow p-1",
className
)}
onClick={(e) => e.stopPropagation()}
{...props}
>
{items.map((item, index) => renderItem(item, index))}
</div>
)
return ReactDOM.createPortal(content, document.body)
}
)
ContextMenuContent.displayName = "ContextMenuContent"
export { ContextMenu, ContextMenuTrigger, ContextMenuContent }Usage
TypeScript:
tsx
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
} from "@/components/ui/context-menu"
const items = [
{ id: "1", label: "Back", shortcut: "⌘ [", onSelect: () => console.log("Back") },
{ id: "2", label: "Forward", shortcut: "⌘ ]", disabled: true },
{ id: "3", label: "Reload", shortcut: "⌘ R", onSelect: () => console.log("Reload") },
{ id: "sep1", separator: true },
{ id: "4", label: "Show Bookmarks Bar", shortcut: "⇧ ⌘ B", checked: true },
]
function MyComponent() {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className="border-2 border-dashed border-foreground p-8 rounded">
Right click here
</div>
</ContextMenuTrigger>
<ContextMenuContent items={items} />
</ContextMenu>
)
}JavaScript:
jsx
import {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
} from "@/components/ui/context-menu"
const items = [
{ id: "1", label: "Back", shortcut: "⌘ [", onSelect: () => console.log("Back") },
{ id: "2", label: "Forward", shortcut: "⌘ ]", disabled: true },
{ id: "3", label: "Reload", shortcut: "⌘ R", onSelect: () => console.log("Reload") },
{ id: "sep1", separator: true },
{ id: "4", label: "Show Bookmarks Bar", shortcut: "⇧ ⌘ B", checked: true },
]
function MyComponent() {
return (
<ContextMenu>
<ContextMenuTrigger>
<div className="border-2 border-dashed border-foreground p-8 rounded">
Right click here
</div>
</ContextMenuTrigger>
<ContextMenuContent items={items} />
</ContextMenu>
)
}Make sure you also have the lib/utils.ts file with the cn helper function.