Web InnoventixFreeCode

Actions Menu

Original · free

actions/kebab menu

byWeb InnoventixReact + Tailwind
menuactionsmenus
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-actions.json
menu-actions.tsx
"use client";

import {
  useState,
  useRef,
  useEffect,
  useMemo,
  useId,
  type ReactNode,
  type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { motion, AnimatePresence, useReducedMotion } from "motion/react";

/* ---------------------------------- icons --------------------------------- */

function ShareIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M12 3v12" />
      <path d="m8 7 4-4 4 4" />
      <path d="M5 12v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6" />
    </svg>
  );
}
function RenameIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M12 20h9" />
      <path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
    </svg>
  );
}
function DuplicateIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <rect x="9" y="9" width="11" height="11" rx="2" />
      <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
    </svg>
  );
}
function MoveIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z" />
    </svg>
  );
}
function DownloadIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M12 3v12" />
      <path d="m7 12 5 5 5-5" />
      <path d="M5 21h14" />
    </svg>
  );
}
function StarIcon({ filled }: { filled: boolean }): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill={filled ? "currentColor" : "none"} stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="m12 3 2.9 5.9 6.5.9-4.7 4.6 1.1 6.5L12 18.8 6.2 21.9l1.1-6.5L2.6 10.8l6.5-.9Z" />
    </svg>
  );
}
function TrashIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.7} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M4 7h16" />
      <path d="M10 11v6" />
      <path d="M14 11v6" />
      <path d="M6 7l1 13a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2l1-13" />
      <path d="M9 7V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v3" />
    </svg>
  );
}
function KebabIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-5 w-5" fill="currentColor" aria-hidden="true">
      <circle cx="12" cy="5" r="1.7" />
      <circle cx="12" cy="12" r="1.7" />
      <circle cx="12" cy="19" r="1.7" />
    </svg>
  );
}
function CheckIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-3.5 w-3.5" fill="none" stroke="currentColor" strokeWidth={2.4} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="m5 12 4 4 10-10" />
    </svg>
  );
}
function UndoIcon(): ReactNode {
  return (
    <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
      <path d="M9 14 4 9l5-5" />
      <path d="M4 9h11a5 5 0 0 1 0 10h-3" />
    </svg>
  );
}

/* ------------------------------- menu model ------------------------------- */

type Divider = { kind: "divider"; id: string };
type Item = {
  kind: "item";
  id: string;
  label: string;
  hint: string;
  shortcut?: string;
  icon: ReactNode;
  tone?: "danger";
  role: "menuitem" | "menuitemcheckbox";
};
type Row = Item | Divider;

const ROWS: Row[] = [
  { kind: "item", id: "share", label: "Share link", hint: "Copy a view-only URL", shortcut: "⌘S", icon: <ShareIcon />, role: "menuitem" },
  { kind: "item", id: "rename", label: "Rename", hint: "Change the project title", shortcut: "F2", icon: <RenameIcon />, role: "menuitem" },
  { kind: "item", id: "duplicate", label: "Duplicate", hint: "Make an editable copy", shortcut: "⌘D", icon: <DuplicateIcon />, role: "menuitem" },
  { kind: "divider", id: "d1" },
  { kind: "item", id: "move", label: "Move to folder", hint: "Reorganize your workspace", icon: <MoveIcon />, role: "menuitem" },
  { kind: "item", id: "download", label: "Export assets", hint: "Download a .zip bundle", shortcut: "⌘E", icon: <DownloadIcon />, role: "menuitem" },
  { kind: "item", id: "favorite", label: "Add to favorites", hint: "Pin to the top of your list", icon: <StarIcon filled={false} />, role: "menuitemcheckbox" },
  { kind: "divider", id: "d2" },
  { kind: "item", id: "delete", label: "Move to trash", hint: "You can undo this", icon: <TrashIcon />, tone: "danger", role: "menuitem" },
];

const ACTIONS = ROWS.filter((r): r is Item => r.kind === "item");

/* -------------------------------- component ------------------------------- */

export default function MenuActions(): ReactNode {
  const prefersReduced = useReducedMotion();

  const [open, setOpen] = useState(false);
  const [activeIndex, setActiveIndex] = useState(0);
  const [favorite, setFavorite] = useState(false);
  const [trashed, setTrashed] = useState(false);
  const [status, setStatus] = useState("No actions yet — open the menu to begin.");

  const triggerRef = useRef<HTMLButtonElement | null>(null);
  const menuRef = useRef<HTMLDivElement | null>(null);
  const undoRef = useRef<HTMLButtonElement | null>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const typeahead = useRef<{ buffer: string; timer: number | null }>({ buffer: "", timer: null });

  const uid = useId();
  const menuId = `${uid}-menu`;
  const triggerId = `${uid}-trigger`;

  const actionIndexById = useMemo(() => {
    const map = new Map<string, number>();
    ACTIONS.forEach((a, i) => map.set(a.id, i));
    return map;
  }, []);

  const n = ACTIONS.length;

  /* focus the active item whenever it changes while open */
  useEffect(() => {
    if (open) itemRefs.current[activeIndex]?.focus();
  }, [open, activeIndex]);

  /* dismiss on outside pointer */
  useEffect(() => {
    if (!open) return;
    const onDown = (e: PointerEvent) => {
      const t = e.target as Node;
      if (menuRef.current?.contains(t) || triggerRef.current?.contains(t)) return;
      setOpen(false);
    };
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  /* return focus to the undo control after a delete */
  useEffect(() => {
    if (trashed) undoRef.current?.focus();
  }, [trashed]);

  const openMenu = (position: "first" | "last") => {
    setActiveIndex(position === "last" ? n - 1 : 0);
    setOpen(true);
  };

  const closeMenu = (focusTrigger = true) => {
    setOpen(false);
    if (focusTrigger) triggerRef.current?.focus();
  };

  const runTypeahead = (ch: string) => {
    const state = typeahead.current;
    if (state.timer) window.clearTimeout(state.timer);
    state.buffer += ch.toLowerCase();
    for (let k = 1; k <= n; k += 1) {
      const idx = (activeIndex + k) % n;
      if (ACTIONS[idx].label.toLowerCase().startsWith(state.buffer)) {
        setActiveIndex(idx);
        break;
      }
    }
    state.timer = window.setTimeout(() => {
      state.buffer = "";
    }, 500);
  };

  const onMenuKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowDown":
        e.preventDefault();
        setActiveIndex((i) => (i + 1) % n);
        break;
      case "ArrowUp":
        e.preventDefault();
        setActiveIndex((i) => (i - 1 + n) % n);
        break;
      case "Home":
        e.preventDefault();
        setActiveIndex(0);
        break;
      case "End":
        e.preventDefault();
        setActiveIndex(n - 1);
        break;
      case "Escape":
        e.preventDefault();
        closeMenu();
        break;
      case "Tab":
        e.preventDefault();
        closeMenu();
        break;
      default:
        if (e.key.length === 1 && /\S/.test(e.key)) runTypeahead(e.key);
    }
  };

  const onTriggerKeyDown = (e: ReactKeyboardEvent<HTMLButtonElement>) => {
    if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      openMenu("first");
    } else if (e.key === "ArrowUp") {
      e.preventDefault();
      openMenu("last");
    }
  };

  const activate = (item: Item) => {
    if (item.role === "menuitemcheckbox") {
      const next = !favorite;
      setFavorite(next);
      setStatus(next ? "Added “Q3 Brand Refresh” to favorites." : "Removed from favorites.");
      return; // checkbox items keep the menu open
    }
    if (item.id === "delete") {
      setOpen(false);
      setTrashed(true);
      setStatus("Moved “Q3 Brand Refresh” to trash.");
      return;
    }
    const messages: Record<string, string> = {
      share: "Copied a view-only link to your clipboard.",
      rename: "Rename mode enabled — type a new title.",
      duplicate: "Duplicated as “Q3 Brand Refresh (copy)”.",
      move: "Choose a destination folder…",
      download: "Preparing your export bundle…",
    };
    setStatus(messages[item.id] ?? `${item.label} triggered.`);
    closeMenu();
  };

  const restore = () => {
    setTrashed(false);
    setStatus("Restored “Q3 Brand Refresh”.");
    window.setTimeout(() => triggerRef.current?.focus(), 0);
  };

  const keyframes = `
    @keyframes mact-ping {
      0% { transform: scale(1); opacity: 0.55; }
      70%, 100% { transform: scale(2.3); opacity: 0; }
    }
    @keyframes mact-sheen {
      0% { background-position: 0% 50%; }
      100% { background-position: 200% 50%; }
    }
    @keyframes mact-rise {
      from { opacity: 0; transform: translateY(8px); }
      to { opacity: 1; transform: translateY(0); }
    }
    @media (prefers-reduced-motion: reduce) {
      .mact-ping, .mact-sheen, .mact-rise { animation: none !important; }
    }
  `;

  return (
    <section className="relative w-full overflow-hidden bg-slate-50 px-4 py-16 text-slate-900 sm:py-24 dark:bg-slate-950 dark:text-slate-100">
      <style>{keyframes}</style>

      {/* atmospheric backdrop */}
      <div aria-hidden="true" className="pointer-events-none absolute -top-24 right-1/2 h-72 w-72 translate-x-1/2 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
      <div aria-hidden="true" className="pointer-events-none absolute -bottom-24 left-10 h-64 w-64 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-700/10" />

      <div className="relative mx-auto max-w-xl">
        <header className="mb-8">
          <span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-slate-500 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-400">
            <span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
            Workspace
          </span>
          <h2 className="mt-4 text-2xl font-semibold tracking-tight text-slate-900 sm:text-3xl dark:text-white">
            Project actions
          </h2>
          <p className="mt-2 text-sm leading-relaxed text-slate-500 dark:text-slate-400">
            Every row gets a kebab menu. Open it, then drive it entirely by keyboard —
            arrow keys to move, type a letter to jump, Escape to close.
          </p>
        </header>

        {/* project card */}
        <div className="relative rounded-2xl border border-slate-200 bg-white shadow-sm shadow-slate-900/5 dark:border-slate-800 dark:bg-slate-900 dark:shadow-black/20">
          {trashed ? (
            <div className="mact-rise flex flex-col items-center gap-3 px-6 py-10 text-center">
              <div className="flex h-12 w-12 items-center justify-center rounded-full bg-rose-100 text-rose-600 dark:bg-rose-500/15 dark:text-rose-400">
                <TrashIcon />
              </div>
              <div>
                <p className="text-sm font-semibold text-slate-900 dark:text-white">Moved to trash</p>
                <p className="mt-0.5 text-xs text-slate-500 dark:text-slate-400">
                  “Q3 Brand Refresh” will be deleted in 30 days.
                </p>
              </div>
              <button
                ref={undoRef}
                type="button"
                onClick={restore}
                className="mt-1 inline-flex items-center gap-2 rounded-lg bg-slate-900 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-slate-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:bg-white dark:text-slate-900 dark:hover:bg-slate-200 dark:focus-visible:ring-offset-slate-900"
              >
                <UndoIcon />
                Undo
              </button>
            </div>
          ) : (
            <div className="flex items-center gap-4 p-4 sm:p-5">
              {/* avatar with animated sheen + status ping */}
              <div className="relative shrink-0">
                <div
                  className="mact-sheen flex h-12 w-12 items-center justify-center rounded-xl text-sm font-bold text-white"
                  style={{
                    backgroundImage:
                      "linear-gradient(110deg, #4f46e5, #7c3aed, #db2777, #4f46e5)",
                    backgroundSize: "200% 100%",
                    animation: "mact-sheen 6s linear infinite",
                  }}
                >
                  Q3
                </div>
                <span className="absolute -right-0.5 -top-0.5 flex h-3 w-3">
                  <span className="mact-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400" style={{ animation: "mact-ping 2s ease-out infinite" }} />
                  <span className="relative inline-flex h-3 w-3 rounded-full border-2 border-white bg-emerald-500 dark:border-slate-900" />
                </span>
              </div>

              <div className="min-w-0 flex-1">
                <div className="flex items-center gap-1.5">
                  <h3 className="truncate text-sm font-semibold text-slate-900 dark:text-white">
                    Q3 Brand Refresh
                  </h3>
                  {favorite && (
                    <span className="text-amber-500 dark:text-amber-400" title="Favorited">
                      <StarIcon filled />
                    </span>
                  )}
                </div>
                <p className="mt-0.5 truncate text-xs text-slate-500 dark:text-slate-400">
                  Edited 2 hours ago · 12 files · Shared with 4
                </p>
              </div>

              {/* trigger + menu */}
              <div className="relative shrink-0">
                <button
                  ref={triggerRef}
                  id={triggerId}
                  type="button"
                  aria-haspopup="menu"
                  aria-expanded={open}
                  aria-controls={open ? menuId : undefined}
                  aria-label="Open actions for Q3 Brand Refresh"
                  onClick={() => (open ? closeMenu() : openMenu("first"))}
                  onKeyDown={onTriggerKeyDown}
                  className={`flex h-9 w-9 items-center justify-center rounded-lg text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-white dark:focus-visible:ring-offset-slate-900 ${
                    open ? "bg-slate-100 text-slate-900 dark:bg-slate-800 dark:text-white" : ""
                  }`}
                >
                  <KebabIcon />
                </button>

                <AnimatePresence>
                  {open && (
                    <motion.div
                      ref={menuRef}
                      id={menuId}
                      role="menu"
                      aria-labelledby={triggerId}
                      aria-orientation="vertical"
                      onKeyDown={onMenuKeyDown}
                      initial={{ opacity: 0, scale: prefersReduced ? 1 : 0.95, y: prefersReduced ? 0 : -6 }}
                      animate={{ opacity: 1, scale: 1, y: 0 }}
                      exit={{ opacity: 0, scale: prefersReduced ? 1 : 0.95, y: prefersReduced ? 0 : -6 }}
                      transition={{ duration: prefersReduced ? 0 : 0.16, ease: [0.16, 1, 0.3, 1] }}
                      style={{ transformOrigin: "top right" }}
                      className="absolute right-0 top-full z-20 mt-2 w-64 origin-top-right overflow-hidden rounded-xl border border-slate-200 bg-white p-1.5 shadow-xl shadow-slate-900/10 dark:border-slate-700 dark:bg-slate-900 dark:shadow-black/40"
                    >
                      <ul className="m-0 list-none p-0">
                        {ROWS.map((row) => {
                          if (row.kind === "divider") {
                            return (
                              <li key={row.id} role="separator" className="my-1.5 h-px bg-slate-200 dark:bg-slate-700" />
                            );
                          }

                          const idx = actionIndexById.get(row.id) ?? 0;
                          const isDanger = row.tone === "danger";
                          const isFav = row.id === "favorite";
                          const label = isFav ? (favorite ? "Remove from favorites" : "Add to favorites") : row.label;

                          return (
                            <li key={row.id} role="none">
                              <button
                                ref={(el) => {
                                  itemRefs.current[idx] = el;
                                }}
                                type="button"
                                role={row.role}
                                aria-checked={row.role === "menuitemcheckbox" ? favorite : undefined}
                                tabIndex={idx === activeIndex ? 0 : -1}
                                onClick={() => activate(row)}
                                onMouseEnter={() => setActiveIndex(idx)}
                                className={`group flex w-full items-start gap-3 rounded-lg px-2.5 py-2 text-left transition-colors focus:outline-none ${
                                  isDanger
                                    ? "text-rose-600 focus-visible:ring-2 focus-visible:ring-rose-500 hover:bg-rose-50 dark:text-rose-400 dark:hover:bg-rose-500/10 dark:focus-visible:ring-rose-500"
                                    : "text-slate-700 focus-visible:ring-2 focus-visible:ring-indigo-500 hover:bg-slate-100 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-indigo-500"
                                }`}
                              >
                                <span
                                  className={`mt-0.5 shrink-0 ${
                                    isDanger
                                      ? "text-rose-500 dark:text-rose-400"
                                      : isFav && favorite
                                        ? "text-amber-500 dark:text-amber-400"
                                        : "text-slate-400 group-hover:text-slate-600 dark:text-slate-500 dark:group-hover:text-slate-300"
                                  }`}
                                >
                                  {isFav ? <StarIcon filled={favorite} /> : row.icon}
                                </span>

                                <span className="min-w-0 flex-1">
                                  <span className="flex items-center justify-between gap-2">
                                    <span className="text-sm font-medium leading-none">{label}</span>
                                    {row.shortcut && (
                                      <kbd className="rounded border border-slate-200 bg-slate-50 px-1.5 py-0.5 text-[10px] font-medium text-slate-400 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-500">
                                        {row.shortcut}
                                      </kbd>
                                    )}
                                    {row.role === "menuitemcheckbox" && favorite && (
                                      <span className="text-emerald-500 dark:text-emerald-400" aria-hidden="true">
                                        <CheckIcon />
                                      </span>
                                    )}
                                  </span>
                                  <span
                                    className={`mt-0.5 block text-xs leading-snug ${
                                      isDanger
                                        ? "text-rose-400/80 dark:text-rose-400/70"
                                        : "text-slate-400 dark:text-slate-500"
                                    }`}
                                  >
                                    {row.hint}
                                  </span>
                                </span>
                              </button>
                            </li>
                          );
                        })}
                      </ul>
                    </motion.div>
                  )}
                </AnimatePresence>
              </div>
            </div>
          )}
        </div>

        {/* live status readout */}
        <div className="mt-4 flex items-center gap-2 rounded-xl border border-slate-200 bg-white/60 px-4 py-3 text-sm text-slate-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/50 dark:text-slate-300">
          <span className="shrink-0 text-xs font-medium uppercase tracking-wider text-slate-400 dark:text-slate-500">
            Log
          </span>
          <span aria-live="polite" className="min-w-0 flex-1 truncate">
            {status}
          </span>
        </div>
      </div>
    </section>
  );
}

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 →