Animated Context Menu New
A right-click menu that opens at the cursor with staggered items, shortcuts and submenus.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/components-context-menu.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ui/motion/ContextMenu.tsx
import { useCallback, useRef, useState, type ComponentType, type MouseEvent, type ReactNode } from 'react';
import { ChevronRight, Copy, Edit3, FolderOpen, MousePointerClick, Share2, Star, Trash2 } from 'lucide-react';
import { cn } from '@/lib/cn';
import { Portal, useClickOutside, useEscape, usePresence } from './hooks';
export type MenuItem =
| { type?: 'item'; label: string; icon?: ComponentType<{ className?: string }>; shortcut?: string; danger?: boolean; submenu?: string[]; onSelect?: () => void }
| { type: 'separator' };
export type ContextMenuProps = {
items: MenuItem[];
children: ReactNode;
className?: string;
};
export function ContextMenu({ items, children, className }: ContextMenuProps) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const [open, setOpen] = useState(false);
const [sub, setSub] = useState<number | null>(null);
const { mounted, visible } = usePresence(open, 160);
const menu = useRef<HTMLDivElement>(null);
const close = useCallback(() => {
setOpen(false);
setSub(null);
}, []);
useEscape(open, close);
useClickOutside([menu], open, close);
const onContext = (e: MouseEvent) => {
e.preventDefault();
const w = 224;
const h = items.length * 34 + 16;
setPos({ x: Math.min(e.clientX, window.innerWidth - w - 8), y: Math.min(e.clientY, window.innerHeight - h - 8) });
setSub(null);
setOpen(false);
requestAnimationFrame(() => setOpen(true));
};
return (
<>
<div onContextMenu={onContext} className={className}>
{children}
</div>
{mounted && pos && (
<Portal>
<div
ref={menu}
role="menu"
style={{ left: pos.x, top: pos.y }}
className={cn(
'fixed z-[100] w-56 origin-top-left rounded-xl bg-ink-900/95 p-1.5 shadow-pop backdrop-blur-xl',
visible ? 'scale-100 opacity-100 transition-all duration-200 ease-spring' : 'scale-95 opacity-0 transition-all duration-150',
)}
>
{items.map((item, i) =>
item.type === 'separator' ? (
<div key={i} className="my-1 h-px bg-white/5" />
) : (
<div key={i} className="relative" onPointerEnter={() => setSub(item.submenu ? i : null)}>
<button
role="menuitem"
onClick={() => {
if (item.submenu) return setSub(i);
item.onSelect?.();
close();
}}
style={{ animationDelay: `${i * 22}ms` }}
className={cn(
'group flex w-full animate-fade-up items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-[13px] outline-none transition-colors',
item.danger ? 'text-rose-300 hover:bg-rose-500/10' : 'text-ink-200 hover:bg-white/[0.06] hover:text-ink-50',
sub === i && 'bg-white/[0.06] text-ink-50',
)}
>
{item.icon && <item.icon className="h-4 w-4 opacity-70 group-hover:opacity-100" />}
<span className="flex-1">{item.label}</span>
{item.shortcut && <span className="font-mono text-[11px] text-ink-500">{item.shortcut}</span>}
{item.submenu && <ChevronRight className="h-3.5 w-3.5 text-ink-500" />}
</button>
{item.submenu && sub === i && (
<div className="absolute left-full top-0 ml-1.5 w-44 origin-left animate-scale-in rounded-xl bg-ink-900/95 p-1.5 shadow-pop backdrop-blur-xl">
{item.submenu.map((s, j) => (
<button
key={s}
onClick={close}
style={{ animationDelay: `${j * 30}ms` }}
className="flex w-full animate-fade-up items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-ink-200 transition-colors hover:bg-white/[0.06] hover:text-ink-50"
>
<span className={cn('h-2 w-2 rounded-full', ['bg-brand-400', 'bg-emerald-400', 'bg-amber-400', 'bg-rose-400'][j % 4])} />
{s}
</button>
))}
</div>
)}
</div>
),
)}
</div>
</Portal>
)}
</>
);
}
export function ContextMenuDemo() {
const [last, setLast] = useState<string | null>(null);
const items: MenuItem[] = [
{ label: 'Open', icon: FolderOpen, shortcut: '↵', onSelect: () => setLast('Opened Q3 Roadmap.fig') },
{ label: 'Rename', icon: Edit3, shortcut: 'F2', onSelect: () => setLast('Renaming…') },
{ label: 'Duplicate', icon: Copy, shortcut: '⌘D', onSelect: () => setLast('Duplicated file') },
{ label: 'Add to favorites', icon: Star, onSelect: () => setLast('Added to favorites') },
{ label: 'Move to', icon: Share2, submenu: ['Design', 'Engineering', 'Marketing', 'Archive'] },
{ type: 'separator' },
{ label: 'Delete', icon: Trash2, shortcut: '⌘⌫', danger: true, onSelect: () => setLast('Moved to trash') },
];
return (
<div className="mx-auto w-full max-w-md">
<ContextMenu items={items}>
<div className="flex h-56 select-none flex-col items-center justify-center gap-3 rounded-2xl border border-dashed border-white/10 bg-ink-900/60 text-center transition-colors hover:border-brand-500/40 hover:bg-ink-900">
<span className="flex h-11 w-11 animate-float items-center justify-center rounded-xl bg-ink-850 text-ink-300 shadow-hairline">
<MousePointerClick className="h-5 w-5" />
</span>
<div>
<p className="text-sm font-medium text-ink-100">Right-click anywhere here</p>
<p className="mt-1 text-xs text-ink-400">Q3 Roadmap.fig · Edited 2h ago</p>
</div>
<p key={last} className={cn('h-4 text-xs text-brand-300', last && 'animate-fade-up')}>{last}</p>
</div>
</ContextMenu>
</div>
);
}components/niceui/ui/motion/hooks.ts
import { useEffect, useState, type ReactNode, type RefObject } from 'react';
import { createPortal } from 'react-dom';
/**
* Keeps an element mounted while it animates out.
* `mounted` controls rendering, `visible` drives the enter/exit classes.
*/
export function usePresence(open: boolean, exitMs = 220) {
const [mounted, setMounted] = useState(open);
const [visible, setVisible] = useState(open);
useEffect(() => {
if (open) {
setMounted(true);
let inner = 0;
const outer = requestAnimationFrame(() => {
inner = requestAnimationFrame(() => setVisible(true));
});
return () => {
cancelAnimationFrame(outer);
cancelAnimationFrame(inner);
};
}
setVisible(false);
const t = window.setTimeout(() => setMounted(false), exitMs);
return () => window.clearTimeout(t);
}, [open, exitMs]);
return { mounted, visible };
}
/** Calls `handler` when Escape is pressed while `active`. */
export function useEscape(active: boolean, handler: () => void) {
useEffect(() => {
if (!active) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') handler();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [active, handler]);
}
/** Calls `handler` on pointerdown outside every provided ref while `active`. */
export function useClickOutside(
refs: Array<RefObject<HTMLElement>>,
active: boolean,
handler: () => void,
) {
useEffect(() => {
if (!active) return;
const onDown = (e: PointerEvent) => {
const target = e.target as Node;
if (refs.some((r) => r.current?.contains(target))) return;
handler();
};
document.addEventListener('pointerdown', onDown);
return () => document.removeEventListener('pointerdown', onDown);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active, handler]);
}
/**
* Renders overlays into document.body so `position: fixed` is relative to the
* viewport even when an ancestor has a transform (e.g. entrance animations).
*/
export function Portal({ children }: { children: ReactNode }) {
if (typeof document === 'undefined') return null;
return createPortal(children, document.body);
}