Installation
Add this component to your project using the CLI:
terminal
npx vui-registry-cli-v1 add ambient-context-menuSource Code
ambient-context-menu.tsx
'use client'
import React, { useState, useEffect, useRef } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { cn } from '@/lib/utils'
import {
Copy,
Trash2,
Share2,
MoreHorizontal,
FolderOpen,
FileText,
Download,
Edit3
} from 'lucide-react'
interface MenuItem {
id: string
label: string
icon?: React.ReactNode
shortcut?: string
variant?: 'default' | 'destructive'
onClick?: () => void
}
interface AmbientContextMenuProps {
items: MenuItem[]
children: React.ReactNode
className?: string
}
export default function AmbientContextMenu({
items,
children,
className,
}: AmbientContextMenuProps) {
const [isOpen, setIsOpen] = useState(false)
const [position, setPosition] = useState({ x: 0, y: 0 })
const [hoveredId, setHoveredId] = useState<string | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = () => setIsOpen(false)
const handleScroll = () => setIsOpen(false)
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setIsOpen(false)
}
window.addEventListener('click', handleClick)
window.addEventListener('contextmenu', handleClick)
window.addEventListener('scroll', handleScroll)
window.addEventListener('keydown', handleKeyDown)
return () => {
window.removeEventListener('click', handleClick)
window.removeEventListener('contextmenu', handleClick)
window.removeEventListener('scroll', handleScroll)
window.removeEventListener('keydown', handleKeyDown)
}
}, [])
const handleContextMenu = (e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
if (!containerRef.current) return
const rect = containerRef.current.getBoundingClientRect()
// Calculate position relative to the container
let x = e.clientX - rect.left
let y = e.clientY - rect.top
// Check if menu would go off screen (relative to container)
const menuWidth = 256
const menuHeight = items.length * 40 + 20
if (x + menuWidth > rect.width) {
x -= menuWidth
}
if (y + menuHeight > rect.height) {
y -= menuHeight
}
setPosition({ x, y })
setIsOpen(true)
}
return (
<div
ref={containerRef}
onContextMenu={handleContextMenu}
className={cn("relative", className)}
>
{children}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, scale: 0.95, filter: 'blur(10px)' }}
animate={{ opacity: 1, scale: 1, filter: 'blur(0px)' }}
exit={{ opacity: 0, scale: 0.95, filter: 'blur(10px)' }}
transition={{ type: "spring", bounce: 0, duration: 0.2 }}
style={{
position: 'absolute',
top: position.y,
left: position.x,
zIndex: 50
}}
className="min-w-[240px] p-2 rounded-2xl bg-white/90 dark:bg-zinc-900/90 backdrop-blur-xl border border-zinc-200/50 dark:border-zinc-800/50 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.15)] ring-1 ring-black/5 overflow-hidden z-[9999]"
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-0.5">
{items.map((item, index) => (
<motion.button
key={item.id}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.03, type: "spring", stiffness: 300, damping: 20 }}
onClick={(e) => {
e.stopPropagation()
item.onClick?.()
setIsOpen(false)
}}
onMouseEnter={() => setHoveredId(item.id)}
onMouseLeave={() => setHoveredId(null)}
className={cn(
"relative flex items-center justify-between w-full px-3 py-2 rounded-xl text-sm font-medium transition-colors outline-none select-none",
item.variant === 'destructive'
? "text-red-600 dark:text-red-400"
: "text-zinc-700 dark:text-zinc-200"
)}
>
{hoveredId === item.id && (
<motion.div
layoutId="menu-hover"
className={cn(
"absolute inset-0 rounded-xl",
item.variant === 'destructive'
? "bg-red-50 dark:bg-red-900/20"
: "bg-zinc-100 dark:bg-zinc-800"
)}
transition={{ type: "spring", bounce: 0, duration: 0.2 }}
/>
)}
<div className="relative flex items-center gap-3 z-10">
{item.icon && (
<span className={cn(
"opacity-70 transition-opacity",
hoveredId === item.id && "opacity-100"
)}>
{item.icon}
</span>
)}
<span>{item.label}</span>
</div>
{item.shortcut && (
<span className="relative z-10 text-xs text-zinc-400 font-sans tracking-wide">
{item.shortcut}
</span>
)}
</motion.button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}
export function AmbientContextMenuDemo() {
const [lastAction, setLastAction] = useState<string | null>(null)
const items: MenuItem[] = [
{
id: 'open',
label: 'Open',
icon: <FolderOpen size={16} />,
onClick: () => setLastAction('Opened file')
},
{
id: 'edit',
label: 'Edit',
icon: <Edit3 size={16} />,
shortcut: '⌘E',
onClick: () => setLastAction('Editing file')
},
{
id: 'copy',
label: 'Copy Link',
icon: <Copy size={16} />,
shortcut: '⌘C',
onClick: () => setLastAction('Copied to clipboard')
},
{
id: 'share',
label: 'Share',
icon: <Share2 size={16} />,
onClick: () => setLastAction('Shared file')
},
{
id: 'download',
label: 'Download',
icon: <Download size={16} />,
shortcut: '⌘D',
onClick: () => setLastAction('Downloaded file')
},
{
id: 'delete',
label: 'Delete',
icon: <Trash2 size={16} />,
variant: 'destructive',
shortcut: '⌫',
onClick: () => setLastAction('Deleted file')
}
]
return (
<AmbientContextMenu items={items} className="w-full h-full min-h-[500px] flex flex-col items-center justify-center bg-zinc-50 dark:bg-zinc-950 p-8">
<div className="text-center mb-12 space-y-3 select-none">
<h3 className="text-3xl font-bold text-zinc-900 dark:text-zinc-100 tracking-tight">
Right-Click Anywhere
</h3>
<p className="text-zinc-500 dark:text-zinc-400 max-w-md mx-auto">
The ambient context menu is context-aware and features fluid entrance animations.
</p>
</div>
<div className="group relative w-full max-w-sm aspect-[4/3] rounded-3xl bg-zinc-950 shadow-2xl border border-zinc-800 overflow-hidden cursor-context-menu transition-all hover:scale-[1.02] duration-500">
{/* Dark Themed Gradient */}
<div className="absolute inset-0 bg-gradient-to-br from-zinc-900 via-zinc-950 to-black opacity-100" />
{/* Double Dotted Lines Pattern */}
<div className="absolute inset-0 opacity-20"
style={{
backgroundImage: 'radial-gradient(circle, #52525b 1px, transparent 1px)',
backgroundSize: '24px 24px'
}}
/>
<div className="absolute inset-0 opacity-10"
style={{
backgroundImage: 'radial-gradient(circle, #52525b 1px, transparent 1px)',
backgroundSize: '16px 16px',
backgroundPosition: '12px 12px'
}}
/>
<div className="absolute inset-0 flex flex-col items-center justify-center gap-6 z-10">
<motion.div
whileHover={{ rotate: 5, scale: 1.1 }}
className="w-20 h-20 rounded-2xl bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-400 shadow-[0_0_40px_-10px_rgba(255,255,255,0.1)]"
>
<FileText size={32} />
</motion.div>
<div className="text-center space-y-1">
<p className="font-medium text-lg text-zinc-200 tracking-tight">project-v1.tsx</p>
<p className="text-xs font-mono text-zinc-500 uppercase tracking-widest">2.4 MB • TSX</p>
</div>
</div>
<div className="absolute top-6 right-6 opacity-0 group-hover:opacity-100 transition-opacity duration-300 z-20">
<div className="p-2 rounded-full hover:bg-zinc-900 transition-colors border border-transparent hover:border-zinc-800">
<MoreHorizontal className="text-zinc-500" size={20} />
</div>
</div>
</div>
<AnimatePresence>
{lastAction && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }}
className="fixed bottom-12 px-6 py-3 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-full text-sm font-semibold shadow-xl z-50"
>
{lastAction}
</motion.div>
)}
</AnimatePresence>
</AmbientContextMenu>
)
}
Dependencies
framer-motion: latestlucide-react: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| items | MenuItem[] | [] | Array of menu items with labels, icons, and actions. |
| children | ReactNode | null | The content that triggers the menu on right-click. |
Context Worth Keeping In Orbit
Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.
I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.
I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.

