Velocity UI
Loading…
Menu

Ambient Context Menu

A custom right-click menu that expands outward with spotlight effects.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add ambient-context-menu

Source 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: latest
  • lucide-react: latest

Props

Component property reference.

NameTypeDefaultDescription
itemsMenuItem[][]Array of menu items with labels, icons, and actions.
childrenReactNodenullThe 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.