Actions Menu
Original · freeactions/kebab menu
byWeb InnoventixReact + Tailwind
menuactionsmenus
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.jsonmenu-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 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

Context Menu
Originalright-click context menu

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

Share Menu
Originalshare menu with options

Notifications Menu
Originalnotifications dropdown list

