Dropdown Menu
Original · freebasic dropdown menu with items
byWeb InnoventixReact + Tailwind
menudropdownmenus
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-dropdown.jsonmenu-dropdown.tsx
"use client";
import {
Fragment,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type MenuAction = {
id: string;
label: string;
shortcut?: string;
icon: ReactNode;
tone?: "default" | "danger";
};
const iconClass = "h-4 w-4 shrink-0";
const ShareIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
<circle cx="18" cy="5" r="3" />
<circle cx="6" cy="12" r="3" />
<circle cx="18" cy="19" r="3" />
<path d="m8.6 13.5 6.8 4M15.4 6.5l-6.8 4" />
</svg>
);
const DuplicateIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} 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>
);
const RenameIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} 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>
);
const MoveIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
<path d="M4 20a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h5l2 3h7a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2Z" />
<path d="M12 15V9M9.5 11.5 12 9l2.5 2.5" />
</svg>
);
const ExportIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
<path d="M12 3v12" />
<path d="m7 12 5 5 5-5" />
<path d="M5 21h14" />
</svg>
);
const HistoryIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
<path d="M3 12a9 9 0 1 0 3-6.7L3 8" />
<path d="M3 3v5h5" />
<path d="M12 8v4l3 2" />
</svg>
);
const TrashIcon = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round" className={iconClass} aria-hidden="true">
<path d="M3 6h18" />
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<path d="M6 6v14a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6" />
<path d="M10 11v6M14 11v6" />
</svg>
);
const groups: MenuAction[][] = [
[
{ id: "share", label: "Share", shortcut: "⇧⌘S", icon: ShareIcon },
{ id: "duplicate", label: "Duplicate", shortcut: "⌘D", icon: DuplicateIcon },
{ id: "rename", label: "Rename", shortcut: "F2", icon: RenameIcon },
],
[
{ id: "move", label: "Move to folder", icon: MoveIcon },
{ id: "export", label: "Export as PDF", shortcut: "⌘E", icon: ExportIcon },
{ id: "history", label: "Version history", icon: HistoryIcon },
],
[
{ id: "delete", label: "Delete project", shortcut: "⌫", icon: TrashIcon, tone: "danger" },
],
];
const flatItems = groups.flat();
const groupStarts = groups.reduce<number[]>((acc, _group, i) => {
acc.push(i === 0 ? 0 : acc[i - 1] + groups[i - 1].length);
return acc;
}, []);
export default function MenuDropdown() {
const reduce = useReducedMotion();
const uid = useId();
const buttonId = `${uid}-trigger`;
const menuId = `${uid}-menu`;
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const [lastAction, setLastAction] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const menuRef = useRef<HTMLDivElement | null>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
const typeahead = useRef<{ str: string; at: number }>({ str: "", at: 0 });
useEffect(() => {
if (!open) return;
itemRefs.current[activeIndex]?.focus();
}, [open, activeIndex]);
useEffect(() => {
if (!open) return;
function onPointerDown(event: PointerEvent) {
const target = event.target as Node | null;
if (!target) return;
if (menuRef.current?.contains(target) || buttonRef.current?.contains(target)) return;
setOpen(false);
}
document.addEventListener("pointerdown", onPointerDown);
return () => document.removeEventListener("pointerdown", onPointerDown);
}, [open]);
function openMenu(edge: "first" | "last") {
setActiveIndex(edge === "first" ? 0 : flatItems.length - 1);
setOpen(true);
}
function closeMenu(returnFocus: boolean) {
setOpen(false);
if (returnFocus) {
requestAnimationFrame(() => buttonRef.current?.focus());
}
}
function moveFocus(next: number) {
const count = flatItems.length;
const idx = ((next % count) + count) % count;
setActiveIndex(idx);
itemRefs.current[idx]?.focus();
}
function focusByChar(char: string) {
const now = Date.now();
const str = now - typeahead.current.at > 600 ? char : typeahead.current.str + char;
typeahead.current = { str, at: now };
const query = str.toLowerCase();
const count = flatItems.length;
const start = str.length > 1 ? activeIndex : activeIndex + 1;
for (let i = 0; i < count; i++) {
const idx = (start + i) % count;
if (flatItems[idx].label.toLowerCase().startsWith(query)) {
moveFocus(idx);
return;
}
}
}
function onSelect(item: MenuAction) {
setLastAction(item.label);
closeMenu(true);
}
function onButtonKeyDown(event: KeyboardEvent<HTMLButtonElement>) {
if (event.key === "ArrowDown") {
event.preventDefault();
openMenu("first");
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu("last");
}
}
function onMenuKeyDown(event: KeyboardEvent<HTMLDivElement>) {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
moveFocus(activeIndex + 1);
break;
case "ArrowUp":
event.preventDefault();
moveFocus(activeIndex - 1);
break;
case "Home":
event.preventDefault();
moveFocus(0);
break;
case "End":
event.preventDefault();
moveFocus(flatItems.length - 1);
break;
case "Escape":
event.preventDefault();
closeMenu(true);
break;
case "Tab":
setOpen(false);
break;
default:
if (event.key.length === 1 && !event.metaKey && !event.ctrlKey && !event.altKey) {
event.preventDefault();
focusByChar(event.key);
}
}
}
const menuMotion = reduce
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.12 },
}
: {
initial: { opacity: 0, y: -8, scale: 0.96 },
animate: { opacity: 1, y: 0, scale: 1 },
exit: { opacity: 0, y: -6, scale: 0.97 },
transition: { duration: 0.18, ease: [0.16, 1, 0.3, 1] as const },
};
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-24 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100 sm:py-28">
<style>{`
@keyframes menudd-ping {
0% { transform: scale(1); opacity: 0.5; }
80%, 100% { transform: scale(2.4); opacity: 0; }
}
.menudd-ping { animation: menudd-ping 2.6s cubic-bezier(0, 0, 0.2, 1) infinite; }
@keyframes menudd-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.menudd-rise { animation: menudd-rise 0.4s ease-out both; }
@media (prefers-reduced-motion: reduce) {
.menudd-ping, .menudd-rise { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(99,102,241,0.10),transparent)] dark:bg-[radial-gradient(60rem_40rem_at_50%_-10%,rgba(129,140,248,0.14),transparent)]"
/>
<div className="relative mx-auto w-full max-w-md">
<p className="menudd-rise mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Workspace
</p>
<div className="menudd-rise rounded-2xl border border-zinc-200 bg-white p-5 shadow-sm shadow-zinc-900/5 dark:border-zinc-800 dark:bg-zinc-900 dark:shadow-black/20">
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-center gap-3">
<div className="relative grid h-11 w-11 shrink-0 place-items-center rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 text-sm font-bold text-white">
Q3
<span className="absolute -right-0.5 -top-0.5 grid h-3.5 w-3.5 place-items-center">
<span className="menudd-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-emerald-500 ring-2 ring-white dark:ring-zinc-900" />
</span>
</div>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-zinc-900 dark:text-zinc-50">
Q3 launch proposal
</h2>
<p className="truncate text-sm text-zinc-500 dark:text-zinc-400">
Edited 12 minutes ago
</p>
</div>
</div>
<div className="relative shrink-0">
<button
ref={buttonRef}
id={buttonId}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => (open ? closeMenu(false) : openMenu("first"))}
onKeyDown={onButtonKeyDown}
className="inline-flex items-center gap-1.5 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200 dark:hover:bg-zinc-700 dark:focus-visible:ring-offset-zinc-900"
>
Options
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={`h-4 w-4 transition-transform duration-200 motion-reduce:transition-none ${open ? "rotate-180" : ""}`}
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
key="menu"
ref={menuRef}
id={menuId}
role="menu"
aria-orientation="vertical"
aria-labelledby={buttonId}
onKeyDown={onMenuKeyDown}
initial={menuMotion.initial}
animate={menuMotion.animate}
exit={menuMotion.exit}
transition={menuMotion.transition}
style={{ transformOrigin: "top right" }}
className="absolute right-0 z-20 mt-2 w-72 rounded-xl border border-zinc-200 bg-white p-1.5 shadow-xl shadow-zinc-900/10 ring-1 ring-black/5 focus:outline-none dark:border-zinc-700 dark:bg-zinc-900 dark:shadow-black/40 dark:ring-white/5"
>
{groups.map((group, gi) => (
<Fragment key={group[0].id}>
{gi > 0 && (
<div role="separator" className="mx-1 my-1 h-px bg-zinc-200 dark:bg-zinc-800" />
)}
{group.map((item, li) => {
const index = groupStarts[gi] + li;
const danger = item.tone === "danger";
return (
<button
key={item.id}
ref={(el) => {
itemRefs.current[index] = el;
}}
type="button"
role="menuitem"
tabIndex={index === activeIndex ? 0 : -1}
onClick={() => onSelect(item)}
onMouseEnter={() => setActiveIndex(index)}
className={`group flex w-full items-center gap-3 rounded-lg px-2.5 py-2 text-left text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-inset ${
danger
? "text-rose-600 hover:bg-rose-50 focus-visible:ring-rose-500 dark:text-rose-400 dark:hover:bg-rose-950/40"
: "text-zinc-700 hover:bg-zinc-100 focus-visible:ring-indigo-500 dark:text-zinc-200 dark:hover:bg-zinc-800"
}`}
>
<span
className={
danger
? "text-rose-500 dark:text-rose-400"
: "text-zinc-400 group-hover:text-indigo-500 dark:text-zinc-500 dark:group-hover:text-indigo-400"
}
>
{item.icon}
</span>
<span className="flex-1 font-medium">{item.label}</span>
{item.shortcut && (
<kbd
className={`rounded border px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${
danger
? "border-rose-200 text-rose-500 dark:border-rose-900/60 dark:text-rose-400/80"
: "border-zinc-200 text-zinc-400 dark:border-zinc-700 dark:text-zinc-500"
}`}
>
{item.shortcut}
</kbd>
)}
</button>
);
})}
</Fragment>
))}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="mt-5 flex items-center justify-between border-t border-zinc-100 pt-4 dark:border-zinc-800">
<span className="text-sm text-zinc-500 dark:text-zinc-400">Last action</span>
<span
aria-live="polite"
className={`text-sm font-semibold ${
lastAction ? "text-zinc-900 dark:text-zinc-100" : "text-zinc-400 dark:text-zinc-600"
}`}
>
{lastAction ?? "None yet"}
</span>
</div>
</div>
<p className="menudd-rise mt-4 text-center text-xs text-zinc-500 dark:text-zinc-500">
Open the menu, then use{" "}
<kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">↑</kbd>{" "}
<kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">↓</kbd> to move,{" "}
<kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">Enter</kbd> to select,{" "}
<kbd className="rounded border border-zinc-300 px-1 py-0.5 text-[10px] dark:border-zinc-700">Esc</kbd> to close.
</p>
</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 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

Actions Menu
Originalactions/kebab menu

Share Menu
Originalshare menu with options

Notifications Menu
Originalnotifications dropdown list

