Context Menu
Original · freeright-click context menu
byWeb InnoventixReact + Tailwind
menucontextmenus
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/menu-context.jsonmenu-context.tsx
"use client";
import {
useState,
useRef,
useEffect,
useLayoutEffect,
useCallback,
} from "react";
import type {
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";
/* ---------- inline icons (no icon libraries) ---------- */
const stroke = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.6,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
};
function IcCut({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<circle cx="6" cy="6" r="2.5" />
<circle cx="6" cy="18" r="2.5" />
<path d="M8.2 7.6 20 18M8.2 16.4 20 6" />
</svg>
);
}
function IcCopy({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<rect x="9" y="9" width="11" height="11" rx="2" />
<path d="M5 15H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h10a1 1 0 0 1 1 1v1" />
</svg>
);
}
function IcPaste({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<rect x="5" y="4" width="14" height="17" rx="2" />
<path d="M9 4V3a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1M9 12h6M9 16h4" />
</svg>
);
}
function IcDuplicate({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<rect x="3.5" y="3.5" width="11" height="11" rx="2" />
<path d="M9 9.5h5.5A2.5 2.5 0 0 1 17 12v5.5" opacity="0" />
<rect x="9.5" y="9.5" width="11" height="11" rx="2" />
</svg>
);
}
function IcRename({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<path d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3Z" />
<path d="M14.5 7.5l2 2" />
</svg>
);
}
function IcArrange({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<path d="M12 3 3 8l9 5 9-5-9-5Z" />
<path d="M3 13l9 5 9-5" />
</svg>
);
}
function IcGrid({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<rect x="3.5" y="3.5" width="17" height="17" rx="2" />
<path d="M9 3.5v17M15 3.5v17M3.5 9h17M3.5 15h17" />
</svg>
);
}
function IcRuler({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<rect x="2.5" y="7.5" width="19" height="9" rx="1.5" transform="rotate(-45 12 12)" />
<path d="M8 8l1.4 1.4M11 11l1.4 1.4M14 14l1.4 1.4M6.5 12.5 8 14" />
</svg>
);
}
function IcZoom({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<circle cx="10.5" cy="10.5" r="6" />
<path d="M15 15l4.5 4.5M8 10.5h5M10.5 8v5" />
</svg>
);
}
function IcTrash({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<path d="M4 6.5h16M9 6.5V4.5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2M6.5 6.5l.8 12a2 2 0 0 0 2 1.9h5.4a2 2 0 0 0 2-1.9l.8-12M10 10.5v6M14 10.5v6" />
</svg>
);
}
function IcChevron({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<path d="m9 6 6 6-6 6" />
</svg>
);
}
function IcCheck({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} {...stroke} aria-hidden="true">
<path d="m5 12.5 4.2 4.2L19 7" />
</svg>
);
}
function IcDot({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<circle cx="12" cy="12" r="3.4" fill="currentColor" />
</svg>
);
}
function IcMore({ className }: { className?: string }) {
return (
<svg viewBox="0 0 24 24" className={className} aria-hidden="true">
<circle cx="5" cy="12" r="1.7" fill="currentColor" />
<circle cx="12" cy="12" r="1.7" fill="currentColor" />
<circle cx="19" cy="12" r="1.7" fill="currentColor" />
</svg>
);
}
/* ---------- data ---------- */
const MAIN_ORDER = [
"cut",
"copy",
"paste",
"duplicate",
"rename",
"arrange",
"snap",
"rulers",
"zoom",
"delete",
] as const;
const ARRANGE_ORDER = ["front", "forward", "backward", "back"] as const;
const ZOOM_ORDER = ["z50", "z75", "z100", "z150"] as const;
const ARRANGE_ITEMS: { id: string; label: string; kbd?: string }[] = [
{ id: "front", label: "Bring to front", kbd: "]" },
{ id: "forward", label: "Bring forward" },
{ id: "backward", label: "Send backward" },
{ id: "back", label: "Send to back", kbd: "[" },
];
const ZOOM_ITEMS: { id: string; label: string; value: number }[] = [
{ id: "z50", label: "50%", value: 50 },
{ id: "z75", label: "75%", value: 75 },
{ id: "z100", label: "100%", value: 100 },
{ id: "z150", label: "150%", value: 150 },
];
const ARRANGE_LABELS: Record<string, string> = {
front: "front",
forward: "forward",
backward: "backward",
back: "back",
};
function move(order: readonly string[], current: string | null, dir: 1 | -1): string {
if (current === null) return dir === 1 ? order[0] : order[order.length - 1];
const i = order.indexOf(current);
if (i === -1) return order[0];
return order[(i + dir + order.length) % order.length];
}
export default function MenuContext() {
const reduce = useReducedMotion();
const [open, setOpen] = useState(false);
const [pos, setPos] = useState({ x: 24, y: 24 });
const [subSide, setSubSide] = useState<"right" | "left">("right");
const [activeId, setActiveId] = useState<string | null>(null);
const [openSub, setOpenSub] = useState<"arrange" | "zoom" | null>(null);
const [subActiveId, setSubActiveId] = useState<string | null>(null);
const [clipboard, setClipboard] = useState<string | null>(null);
const [snap, setSnap] = useState(false);
const [rulers, setRulers] = useState(true);
const [zoom, setZoom] = useState(100);
const [orderLabel, setOrderLabel] = useState("front");
const [pasteCount, setPasteCount] = useState(0);
const [deleted, setDeleted] = useState(false);
const [layerName, setLayerName] = useState("Hero / Artboard 01");
const [status, setStatus] = useState("Ready — right-click the canvas to begin.");
const [renaming, setRenaming] = useState(false);
const [tempName, setTempName] = useState(layerName);
const canvasRef = useRef<HTMLDivElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Record<string, HTMLButtonElement | null>>({});
const lastTrigger = useRef<HTMLElement | null>(null);
const renameRef = useRef<HTMLInputElement | null>(null);
const setItemRef = useCallback(
(id: string) => (el: HTMLButtonElement | null) => {
itemRefs.current[id] = el;
},
[]
);
const closeAll = useCallback((refocus = true) => {
setOpen(false);
setOpenSub(null);
setSubActiveId(null);
if (refocus) {
const t = lastTrigger.current;
window.requestAnimationFrame(() => t?.focus());
}
}, []);
const openAt = useCallback((x: number, y: number, trigger: HTMLElement | null) => {
lastTrigger.current = trigger;
const cw = canvasRef.current?.clientWidth ?? 0;
setSubSide(x > cw * 0.52 ? "left" : "right");
setPos({ x, y });
setOpenSub(null);
setSubActiveId(null);
setActiveId(MAIN_ORDER[0]);
setOpen(true);
}, []);
const openSubmenu = useCallback((id: "arrange" | "zoom") => {
setActiveId(id);
setSubActiveId(id === "arrange" ? ARRANGE_ORDER[0] : ZOOM_ORDER[0]);
setOpenSub(id);
}, []);
const closeSubmenu = useCallback(() => {
setOpenSub(null);
setSubActiveId(null);
}, []);
/* clamp menu inside the canvas after it renders */
useLayoutEffect(() => {
if (!open || !menuRef.current || !canvasRef.current) return;
const m = menuRef.current.getBoundingClientRect();
const c = canvasRef.current.getBoundingClientRect();
let { x, y } = pos;
if (x + m.width > c.width - 10) x = Math.max(10, c.width - m.width - 10);
if (y + m.height > c.height - 10) y = Math.max(10, c.height - m.height - 10);
if (x !== pos.x || y !== pos.y) setPos({ x, y });
}, [open, pos]);
/* roving focus — main */
useEffect(() => {
if (open && openSub === null && activeId) itemRefs.current[activeId]?.focus();
}, [open, openSub, activeId]);
/* roving focus — submenu */
useEffect(() => {
if (open && openSub !== null && subActiveId) itemRefs.current[subActiveId]?.focus();
}, [open, openSub, subActiveId]);
/* focus rename field */
useEffect(() => {
if (renaming) {
renameRef.current?.focus();
renameRef.current?.select();
}
}, [renaming]);
/* outside click / escape / resize */
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) closeAll();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
closeAll();
}
};
const onResize = () => closeAll(false);
document.addEventListener("pointerdown", onDown, true);
document.addEventListener("keydown", onKey);
window.addEventListener("resize", onResize);
return () => {
document.removeEventListener("pointerdown", onDown, true);
document.removeEventListener("keydown", onKey);
window.removeEventListener("resize", onResize);
};
}, [open, closeAll]);
/* ---------- actions ---------- */
const runAction = (id: string) => {
switch (id) {
case "cut":
setClipboard(layerName);
setStatus(`Cut “${layerName}” to the clipboard.`);
break;
case "copy":
setClipboard(layerName);
setStatus(`Copied “${layerName}” to the clipboard.`);
break;
case "paste":
if (!clipboard) return;
setPasteCount((n) => n + 1);
setStatus(`Pasted “${clipboard}” onto the canvas.`);
break;
case "duplicate":
setPasteCount((n) => n + 1);
setStatus(`Duplicated “${layerName}” with a 12px offset.`);
break;
case "rename":
setTempName(layerName);
setStatus("Renaming layer… press Enter to confirm.");
setRenaming(true);
closeAll(false);
return;
case "snap":
setSnap((v) => !v);
setStatus(`Snap to grid ${!snap ? "enabled" : "disabled"}.`);
break;
case "rulers":
setRulers((v) => !v);
setStatus(`Rulers ${!rulers ? "shown" : "hidden"}.`);
break;
case "delete":
setDeleted(true);
setStatus(`Deleted “${layerName}”. You can restore it.`);
break;
default:
break;
}
closeAll();
};
const activateMain = (id: string | null) => {
if (!id) return;
if (id === "arrange" || id === "zoom") {
openSubmenu(id);
return;
}
runAction(id);
};
const activateSub = (id: string) => {
if (id.startsWith("z")) {
const item = ZOOM_ITEMS.find((z) => z.id === id);
if (item) {
setZoom(item.value);
setStatus(`Zoom set to ${item.value}%.`);
}
} else {
setOrderLabel(ARRANGE_LABELS[id]);
setStatus(`Moved layer to ${ARRANGE_LABELS[id]}.`);
}
closeAll();
};
const isPasteDisabled = clipboard === null;
/* ---------- keyboard ---------- */
const onMainKey = (e: ReactKeyboardEvent<HTMLDivElement>) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
closeSubmenu();
setActiveId((c) => move(MAIN_ORDER, c, 1));
break;
case "ArrowUp":
e.preventDefault();
closeSubmenu();
setActiveId((c) => move(MAIN_ORDER, c, -1));
break;
case "Home":
e.preventDefault();
setActiveId(MAIN_ORDER[0]);
break;
case "End":
e.preventDefault();
setActiveId(MAIN_ORDER[MAIN_ORDER.length - 1]);
break;
case "ArrowRight":
if (activeId === "arrange" || activeId === "zoom") {
e.preventDefault();
openSubmenu(activeId);
}
break;
case "Enter":
case " ":
e.preventDefault();
activateMain(activeId);
break;
default:
break;
}
};
const onSubKey = (e: ReactKeyboardEvent<HTMLDivElement>, order: readonly string[]) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
e.stopPropagation();
setSubActiveId((c) => move(order, c, 1));
break;
case "ArrowUp":
e.preventDefault();
e.stopPropagation();
setSubActiveId((c) => move(order, c, -1));
break;
case "Home":
e.preventDefault();
e.stopPropagation();
setSubActiveId(order[0]);
break;
case "End":
e.preventDefault();
e.stopPropagation();
setSubActiveId(order[order.length - 1]);
break;
case "ArrowLeft":
case "Escape":
e.preventDefault();
e.stopPropagation();
closeSubmenu();
break;
case "Enter":
case " ":
e.preventDefault();
e.stopPropagation();
if (subActiveId) activateSub(subActiveId);
break;
case "ArrowRight":
e.stopPropagation();
break;
default:
break;
}
};
/* ---------- triggers ---------- */
const onCanvasContext = (e: ReactMouseEvent<HTMLDivElement>) => {
e.preventDefault();
if (deleted) return;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
openAt(e.clientX - rect.left, e.clientY - rect.top, canvasRef.current);
};
const onMoreClick = (e: ReactMouseEvent<HTMLButtonElement>) => {
const btn = e.currentTarget;
const rect = canvasRef.current?.getBoundingClientRect();
if (!rect) return;
const r = btn.getBoundingClientRect();
openAt(r.left - rect.left - 200, r.bottom - rect.top + 8, btn);
};
const commitRename = () => {
const next = tempName.trim() || layerName;
setLayerName(next);
setRenaming(false);
setStatus(`Renamed layer to “${next}”.`);
};
const onRenameKey = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename();
} else if (e.key === "Escape") {
e.preventDefault();
setTempName(layerName);
setRenaming(false);
setStatus("Rename cancelled.");
}
};
/* ---------- motion ---------- */
const menuAnim = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.1 } }
: {
initial: { opacity: 0, scale: 0.94, y: -6 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.97, y: -3 },
transition: { duration: 0.16, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
};
const subAnim = reduce
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: { duration: 0.1 } }
: {
initial: { opacity: 0, scale: 0.95, x: subSide === "right" ? -6 : 6 },
animate: { opacity: 1, scale: 1, x: 0 },
exit: { opacity: 0, scale: 0.97 },
transition: { duration: 0.14, ease: [0.16, 1, 0.3, 1] as [number, number, number, number] },
};
/* ---------- styles ---------- */
const rowCls = (id: string, danger = false, disabled = false) =>
[
"group relative flex w-full cursor-default items-center gap-2.5 rounded-lg px-2.5 py-[7px] text-left text-[13px] font-medium outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-indigo-500/70 dark:focus-visible:ring-indigo-400/70",
disabled
? "cursor-not-allowed text-slate-400 dark:text-slate-600"
: activeId === id
? danger
? "bg-rose-500/12 text-rose-700 dark:bg-rose-400/15 dark:text-rose-200"
: "bg-indigo-500/12 text-indigo-800 dark:bg-indigo-400/15 dark:text-indigo-50"
: danger
? "text-rose-600/90 dark:text-rose-300/90"
: "text-slate-700 dark:text-slate-200",
].join(" ");
const subRowCls = (id: string) =>
[
"group relative flex w-full cursor-default items-center gap-2.5 rounded-lg px-2.5 py-[7px] text-left text-[13px] font-medium outline-none transition-colors",
"focus-visible:ring-2 focus-visible:ring-indigo-500/70 dark:focus-visible:ring-indigo-400/70",
subActiveId === id
? "bg-indigo-500/12 text-indigo-800 dark:bg-indigo-400/15 dark:text-indigo-50"
: "text-slate-700 dark:text-slate-200",
].join(" ");
const iconCls = "h-[15px] w-[15px] shrink-0 opacity-80";
const kbdCls =
"ml-auto shrink-0 rounded-md px-1.5 py-0.5 text-[10.5px] font-normal tracking-wide text-slate-400 ring-1 ring-inset ring-slate-200/70 dark:text-slate-500 dark:ring-slate-700/70";
const sepCls = "my-1 h-px bg-slate-200/80 dark:bg-slate-700/60";
const subPanelBase =
"absolute top-[-6px] z-10 w-52 rounded-xl border border-slate-200/80 bg-white/95 p-1.5 shadow-2xl shadow-slate-900/20 ring-1 ring-black/[0.02] backdrop-blur-xl dark:border-slate-700/70 dark:bg-slate-900/95 dark:shadow-black/50";
const scaleStyle = { transform: `scale(${zoom / 100})` };
return (
<section className="relative w-full overflow-hidden bg-gradient-to-b from-slate-50 via-white to-slate-100 px-5 py-16 text-slate-900 sm:px-8 sm:py-24 dark:from-slate-950 dark:via-slate-950 dark:to-slate-900 dark:text-slate-100">
<style>{`
@keyframes menuctx-ping { 0% { transform: scale(1); opacity: .55; } 70%,100% { transform: scale(2.4); opacity: 0; } }
@keyframes menuctx-sheen { 0% { background-position: -180% 0; } 100% { background-position: 180% 0; } }
.menuctx-sheen { background-size: 220% 100%; animation: menuctx-sheen 4.5s linear infinite; }
@media (prefers-reduced-motion: reduce) {
.menuctx-ping, .menuctx-sheen { animation: none !important; }
}
`}</style>
{/* atmosphere */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 opacity-70"
style={{
backgroundImage:
"radial-gradient(60% 45% at 15% 0%, rgba(99,102,241,0.10), transparent 60%), radial-gradient(55% 45% at 95% 100%, rgba(139,92,246,0.10), transparent 60%)",
}}
/>
<div className="relative mx-auto max-w-4xl">
{/* heading */}
<div className="mb-8 flex flex-col gap-3 sm:mb-10">
<span className="inline-flex w-fit items-center gap-2 rounded-full bg-indigo-500/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-indigo-600 ring-1 ring-inset ring-indigo-500/20 dark:bg-indigo-400/10 dark:text-indigo-300 dark:ring-indigo-400/20">
<span className="menuctx-sheen bg-gradient-to-r from-transparent via-indigo-500 to-transparent bg-clip-text text-transparent">
Canvas control
</span>
</span>
<h2 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
Right-click to command the canvas
</h2>
<p className="max-w-xl text-[15px] leading-relaxed text-slate-500 dark:text-slate-400">
A real context menu with roving keyboard focus, nested submenus, checkbox
and radio items. Every action below genuinely changes the artboard — no fake
state.
</p>
</div>
{/* canvas */}
<div
ref={canvasRef}
onContextMenu={onCanvasContext}
tabIndex={0}
role="application"
aria-label="Design canvas. Right-click, or use the More actions button, to open the context menu."
className="relative h-[460px] w-full select-none overflow-hidden rounded-2xl border border-slate-200/90 bg-slate-100 shadow-[0_1px_0_rgba(255,255,255,0.6)_inset,0_30px_60px_-30px_rgba(15,23,42,0.35)] outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white sm:h-[520px] dark:border-slate-800 dark:bg-slate-900 dark:shadow-[0_1px_0_rgba(255,255,255,0.04)_inset,0_30px_60px_-30px_rgba(0,0,0,0.7)] dark:focus-visible:ring-offset-slate-950"
style={{
backgroundImage:
"radial-gradient(rgba(100,116,139,0.18) 1px, transparent 1px)",
backgroundSize: "22px 22px",
}}
>
{/* snap grid overlay */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 transition-opacity duration-300"
style={{
opacity: snap ? 1 : 0,
backgroundImage:
"linear-gradient(rgba(99,102,241,0.22) 1px, transparent 1px), linear-gradient(90deg, rgba(99,102,241,0.22) 1px, transparent 1px)",
backgroundSize: "44px 44px",
}}
/>
{/* rulers */}
{rulers && (
<>
<div
aria-hidden="true"
className="pointer-events-none absolute left-0 right-0 top-0 h-5 border-b border-slate-300/70 bg-white/60 backdrop-blur-sm dark:border-slate-700/70 dark:bg-slate-950/50"
style={{
backgroundImage:
"repeating-linear-gradient(90deg, rgba(100,116,139,0.5) 0 1px, transparent 1px 44px)",
}}
/>
<div
aria-hidden="true"
className="pointer-events-none absolute bottom-0 left-0 top-0 w-5 border-r border-slate-300/70 bg-white/60 backdrop-blur-sm dark:border-slate-700/70 dark:bg-slate-950/50"
style={{
backgroundImage:
"repeating-linear-gradient(0deg, rgba(100,116,139,0.5) 0 1px, transparent 1px 44px)",
}}
/>
</>
)}
{/* toolbar */}
<div className="absolute left-1/2 top-8 z-20 flex -translate-x-1/2 items-center gap-1.5 rounded-xl border border-slate-200/80 bg-white/85 p-1 shadow-lg shadow-slate-900/10 backdrop-blur-md dark:border-slate-700/70 dark:bg-slate-900/80">
{renaming ? (
<input
ref={renameRef}
value={tempName}
onChange={(e) => setTempName(e.target.value)}
onKeyDown={onRenameKey}
onBlur={commitRename}
aria-label="Rename layer"
className="w-52 rounded-lg bg-transparent px-2.5 py-1.5 text-[13px] font-medium text-slate-800 outline-none ring-2 ring-indigo-500/70 dark:text-slate-100 dark:ring-indigo-400/70"
/>
) : (
<span className="px-2.5 py-1.5 text-[13px] font-semibold text-slate-700 dark:text-slate-200">
{deleted ? "No layer selected" : layerName}
</span>
)}
<button
type="button"
onClick={onMoreClick}
disabled={deleted}
aria-haspopup="menu"
aria-expanded={open}
aria-label="More actions"
className="rounded-lg p-1.5 text-slate-500 outline-none transition-colors hover:bg-slate-500/10 focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:opacity-40 dark:text-slate-400 dark:hover:bg-white/5"
>
<IcMore className="h-4 w-4" />
</button>
</div>
{/* selected layer OR empty state */}
{deleted ? (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4">
<p className="text-sm text-slate-400 dark:text-slate-500">
The artboard is empty.
</p>
<button
type="button"
onClick={() => {
setDeleted(false);
setStatus(`Restored “${layerName}”.`);
}}
className="rounded-lg bg-indigo-600 px-4 py-2 text-[13px] font-semibold text-white shadow-lg shadow-indigo-600/30 outline-none transition-colors hover:bg-indigo-500 focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-100 dark:focus-visible:ring-offset-slate-900"
>
Restore layer
</button>
</div>
) : (
<div className="absolute inset-0 flex items-center justify-center">
<div
className="origin-center transition-transform duration-300 ease-out"
style={scaleStyle}
>
<div className="relative w-72 overflow-hidden rounded-2xl border border-white/40 bg-gradient-to-br from-indigo-500 via-violet-500 to-sky-500 p-5 shadow-2xl shadow-indigo-900/30 ring-1 ring-indigo-500/40">
<div className="absolute inset-0 opacity-20" style={{ backgroundImage: "radial-gradient(circle at 30% 20%, white, transparent 45%)" }} />
<div className="relative flex items-center justify-between">
<span className="rounded-md bg-white/20 px-2 py-0.5 text-[11px] font-semibold text-white backdrop-blur">
{orderLabel} · z-{orderLabel === "front" ? 40 : orderLabel === "forward" ? 30 : orderLabel === "backward" ? 20 : 10}
</span>
<span className="text-[11px] font-medium text-white/80">{zoom}%</span>
</div>
<div className="relative mt-6 space-y-2.5">
<div className="h-2.5 w-3/4 rounded-full bg-white/70" />
<div className="h-2.5 w-1/2 rounded-full bg-white/40" />
<div className="mt-4 flex gap-2">
<div className="h-9 w-9 rounded-lg bg-white/25" />
<div className="h-9 flex-1 rounded-lg bg-white/15" />
</div>
</div>
{pasteCount > 0 && (
<span className="absolute -right-2 -top-2 flex h-6 min-w-6 items-center justify-center rounded-full bg-emerald-400 px-1.5 text-[11px] font-bold text-emerald-950 shadow-lg">
{pasteCount}
</span>
)}
</div>
</div>
</div>
)}
{/* hint */}
{!open && !deleted && !renaming && (
<div className="pointer-events-none absolute bottom-14 left-1/2 flex -translate-x-1/2 items-center gap-2.5 rounded-full border border-slate-200/80 bg-white/85 px-3.5 py-1.5 text-[12px] font-medium text-slate-500 shadow-sm backdrop-blur dark:border-slate-700/70 dark:bg-slate-900/80 dark:text-slate-400">
<span className="relative flex h-2 w-2">
<span className="menuctx-ping absolute inline-flex h-full w-full rounded-full bg-indigo-500" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-indigo-500" />
</span>
Right-click anywhere on the canvas
</div>
)}
{/* ---------- the context menu ---------- */}
<AnimatePresence>
{open && (
<motion.div
ref={menuRef}
initial={menuAnim.initial}
animate={menuAnim.animate}
exit={menuAnim.exit}
transition={menuAnim.transition}
role="menu"
aria-label={`Actions for ${layerName}`}
aria-orientation="vertical"
onKeyDown={onMainKey}
style={{ left: pos.x, top: pos.y, transformOrigin: "top left" }}
className="absolute z-30 w-60 rounded-xl border border-slate-200/80 bg-white/95 p-1.5 shadow-2xl shadow-slate-900/25 ring-1 ring-black/[0.02] backdrop-blur-xl dark:border-slate-700/70 dark:bg-slate-900/95 dark:shadow-black/60"
>
{/* context header */}
<div className="flex items-center gap-2 px-2.5 pb-1.5 pt-1">
<span className="h-2 w-2 rounded-full bg-gradient-to-br from-indigo-500 to-violet-500" />
<span className="truncate text-[11px] font-semibold uppercase tracking-wider text-slate-400 dark:text-slate-500">
{layerName}
</span>
</div>
<div className={sepCls} />
{/* cut */}
<button
ref={setItemRef("cut")}
type="button"
role="menuitem"
tabIndex={activeId === "cut" ? 0 : -1}
onMouseEnter={() => {
setActiveId("cut");
closeSubmenu();
}}
onClick={() => runAction("cut")}
className={rowCls("cut")}
>
<IcCut className={iconCls} />
Cut
<kbd className={kbdCls}>Ctrl X</kbd>
</button>
{/* copy */}
<button
ref={setItemRef("copy")}
type="button"
role="menuitem"
tabIndex={activeId === "copy" ? 0 : -1}
onMouseEnter={() => {
setActiveId("copy");
closeSubmenu();
}}
onClick={() => runAction("copy")}
className={rowCls("copy")}
>
<IcCopy className={iconCls} />
Copy
<kbd className={kbdCls}>Ctrl C</kbd>
</button>
{/* paste */}
<button
ref={setItemRef("paste")}
type="button"
role="menuitem"
aria-disabled={isPasteDisabled}
tabIndex={activeId === "paste" ? 0 : -1}
onMouseEnter={() => {
setActiveId("paste");
closeSubmenu();
}}
onClick={() => {
if (!isPasteDisabled) runAction("paste");
}}
className={rowCls("paste", false, isPasteDisabled)}
>
<IcPaste className={iconCls} />
Paste
<kbd className={kbdCls}>Ctrl V</kbd>
</button>
<div className={sepCls} />
{/* duplicate */}
<button
ref={setItemRef("duplicate")}
type="button"
role="menuitem"
tabIndex={activeId === "duplicate" ? 0 : -1}
onMouseEnter={() => {
setActiveId("duplicate");
closeSubmenu();
}}
onClick={() => runAction("duplicate")}
className={rowCls("duplicate")}
>
<IcDuplicate className={iconCls} />
Duplicate
<kbd className={kbdCls}>Ctrl D</kbd>
</button>
{/* rename */}
<button
ref={setItemRef("rename")}
type="button"
role="menuitem"
tabIndex={activeId === "rename" ? 0 : -1}
onMouseEnter={() => {
setActiveId("rename");
closeSubmenu();
}}
onClick={() => runAction("rename")}
className={rowCls("rename")}
>
<IcRename className={iconCls} />
Rename layer
<kbd className={kbdCls}>F2</kbd>
</button>
{/* arrange submenu */}
<div className="relative">
<button
ref={setItemRef("arrange")}
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={openSub === "arrange"}
tabIndex={activeId === "arrange" ? 0 : -1}
onMouseEnter={() => openSubmenu("arrange")}
onClick={() => openSubmenu("arrange")}
className={rowCls("arrange")}
>
<IcArrange className={iconCls} />
Arrange
<IcChevron className="ml-auto h-3.5 w-3.5 opacity-60" />
</button>
<AnimatePresence>
{openSub === "arrange" && (
<motion.div
initial={subAnim.initial}
animate={subAnim.animate}
exit={subAnim.exit}
transition={subAnim.transition}
role="menu"
aria-label="Arrange layer"
onKeyDown={(e) => onSubKey(e, ARRANGE_ORDER)}
style={{ transformOrigin: subSide === "right" ? "left top" : "right top" }}
className={`${subPanelBase} ${subSide === "right" ? "left-full ml-1.5" : "right-full mr-1.5"}`}
>
{ARRANGE_ITEMS.map((it) => (
<button
key={it.id}
ref={setItemRef(it.id)}
type="button"
role="menuitem"
tabIndex={subActiveId === it.id ? 0 : -1}
onMouseEnter={() => setSubActiveId(it.id)}
onClick={() => activateSub(it.id)}
className={subRowCls(it.id)}
>
{it.label}
{it.kbd && <kbd className={kbdCls}>{it.kbd}</kbd>}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<div className={sepCls} />
{/* snap — checkbox */}
<button
ref={setItemRef("snap")}
type="button"
role="menuitemcheckbox"
aria-checked={snap}
tabIndex={activeId === "snap" ? 0 : -1}
onMouseEnter={() => {
setActiveId("snap");
closeSubmenu();
}}
onClick={() => runAction("snap")}
className={rowCls("snap")}
>
<IcGrid className={iconCls} />
Snap to grid
<span className="ml-auto flex h-4 w-4 items-center justify-center">
{snap && <IcCheck className="h-4 w-4 text-emerald-500" />}
</span>
</button>
{/* rulers — checkbox */}
<button
ref={setItemRef("rulers")}
type="button"
role="menuitemcheckbox"
aria-checked={rulers}
tabIndex={activeId === "rulers" ? 0 : -1}
onMouseEnter={() => {
setActiveId("rulers");
closeSubmenu();
}}
onClick={() => runAction("rulers")}
className={rowCls("rulers")}
>
<IcRuler className={iconCls} />
Show rulers
<span className="ml-auto flex h-4 w-4 items-center justify-center">
{rulers && <IcCheck className="h-4 w-4 text-emerald-500" />}
</span>
</button>
{/* zoom submenu — radios */}
<div className="relative">
<button
ref={setItemRef("zoom")}
type="button"
role="menuitem"
aria-haspopup="menu"
aria-expanded={openSub === "zoom"}
tabIndex={activeId === "zoom" ? 0 : -1}
onMouseEnter={() => openSubmenu("zoom")}
onClick={() => openSubmenu("zoom")}
className={rowCls("zoom")}
>
<IcZoom className={iconCls} />
Zoom level
<IcChevron className="ml-auto h-3.5 w-3.5 opacity-60" />
</button>
<AnimatePresence>
{openSub === "zoom" && (
<motion.div
initial={subAnim.initial}
animate={subAnim.animate}
exit={subAnim.exit}
transition={subAnim.transition}
role="menu"
aria-label="Zoom level"
onKeyDown={(e) => onSubKey(e, ZOOM_ORDER)}
style={{ transformOrigin: subSide === "right" ? "left top" : "right top" }}
className={`${subPanelBase} ${subSide === "right" ? "left-full ml-1.5" : "right-full mr-1.5"}`}
>
{ZOOM_ITEMS.map((it) => (
<button
key={it.id}
ref={setItemRef(it.id)}
type="button"
role="menuitemradio"
aria-checked={zoom === it.value}
tabIndex={subActiveId === it.id ? 0 : -1}
onMouseEnter={() => setSubActiveId(it.id)}
onClick={() => activateSub(it.id)}
className={subRowCls(it.id)}
>
<span className="flex h-4 w-4 items-center justify-center">
{zoom === it.value ? (
<IcDot className="h-4 w-4 text-indigo-500" />
) : (
<span className="h-3 w-3 rounded-full ring-1 ring-inset ring-slate-300 dark:ring-slate-600" />
)}
</span>
{it.label}
</button>
))}
</motion.div>
)}
</AnimatePresence>
</div>
<div className={sepCls} />
{/* delete — danger */}
<button
ref={setItemRef("delete")}
type="button"
role="menuitem"
tabIndex={activeId === "delete" ? 0 : -1}
onMouseEnter={() => {
setActiveId("delete");
closeSubmenu();
}}
onClick={() => runAction("delete")}
className={rowCls("delete", true)}
>
<IcTrash className={iconCls} />
Delete layer
<kbd className={kbdCls}>Del</kbd>
</button>
</motion.div>
)}
</AnimatePresence>
{/* status bar */}
<div
role="status"
aria-live="polite"
className="absolute inset-x-0 bottom-0 z-10 flex items-center gap-2 border-t border-slate-200/70 bg-white/80 px-4 py-2 text-[12px] font-medium text-slate-500 backdrop-blur dark:border-slate-800/70 dark:bg-slate-950/60 dark:text-slate-400"
>
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-emerald-500" />
<span className="truncate">{status}</span>
</div>
</div>
{/* keyboard legend */}
<div className="mt-5 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12px] text-slate-500 dark:text-slate-400">
<LegendKey k="↑ ↓">navigate</LegendKey>
<LegendKey k="→">open submenu</LegendKey>
<LegendKey k="← Esc">close / back</LegendKey>
<LegendKey k="Enter">activate</LegendKey>
<LegendKey k="Home End">jump</LegendKey>
</div>
</div>
</section>
);
}
function LegendKey({ k, children }: { k: string; children: string }) {
return (
<span className="inline-flex items-center gap-1.5">
<kbd className="rounded-md bg-slate-200/70 px-1.5 py-0.5 text-[11px] font-semibold text-slate-600 dark:bg-slate-800 dark:text-slate-300">
{k}
</kbd>
{children}
</span>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Dropdown Menu
Originalbasic dropdown menu with items
Dropdown Icons Menu
Originaldropdown with icons and shortcuts

User Menu
Originaluser account dropdown with avatar

Mega Menu
Originalmega menu with grouped links

Mega Cols Menu
Originalmulti-column mega menu with featured

Nested Menu
Originaldropdown with nested submenus

Select Menu
Originalcustom single select dropdown

Multiselect Menu
Originalmulti-select with checkable items

Command Menu
Originalcommand palette style menu with search

Actions Menu
Originalactions/kebab menu

Share Menu
Originalshare menu with options

Notifications Menu
Originalnotifications dropdown list

