Web InnoventixFreeCode

Context Menu

Original · free

right-click context menu

byWeb InnoventixReact + Tailwind
menucontextmenus
Open

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.json
menu-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 quote

Similar components

Browse all →