User Menu
Original · freeuser account dropdown with avatar
byWeb InnoventixReact + Tailwind
menuusermenus
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-user.jsonmenu-user.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
} from "react";
import type { KeyboardEvent, ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type StatusId = "active" | "away" | "dnd";
type StatusMeta = {
id: StatusId;
label: string;
hint: string;
dot: string;
text: string;
};
type NavItem = {
id: string;
label: string;
hint: string;
icon: ReactNode;
};
const STATUSES: StatusMeta[] = [
{
id: "active",
label: "Active",
hint: "Available for messages",
dot: "bg-emerald-500",
text: "text-emerald-600 dark:text-emerald-400",
},
{
id: "away",
label: "Away",
hint: "Idle for 20 minutes",
dot: "bg-amber-500",
text: "text-amber-600 dark:text-amber-400",
},
{
id: "dnd",
label: "Do not disturb",
hint: "Notifications paused",
dot: "bg-rose-500",
text: "text-rose-600 dark:text-rose-400",
},
];
const IconProfile = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<circle cx="12" cy="8" r="3.5" />
<path d="M4.5 20a7.5 7.5 0 0 1 15 0" />
</svg>
);
const IconBilling = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<rect x="3" y="5" width="18" height="14" rx="2.5" />
<path d="M3 9.5h18" />
<path d="M6.5 14.5h4" />
</svg>
);
const IconTeam = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<circle cx="9" cy="8" r="3" />
<path d="M3.5 19a5.5 5.5 0 0 1 11 0" />
<path d="M16 6.2a3 3 0 0 1 0 5.6" />
<path d="M17.5 13.4A5.5 5.5 0 0 1 20.5 18" />
</svg>
);
const IconSettings = (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<circle cx="12" cy="12" r="3" />
<path d="M12 2.5v2.2M12 19.3v2.2M4.2 4.2l1.6 1.6M18.2 18.2l1.6 1.6M2.5 12h2.2M19.3 12h2.2M4.2 19.8l1.6-1.6M18.2 5.8l1.6-1.6" />
</svg>
);
const NAV: NavItem[] = [
{ id: "profile", label: "Your profile", hint: "View public profile", icon: IconProfile },
{ id: "billing", label: "Billing & invoices", hint: "Team plan · renews Aug 1", icon: IconBilling },
{ id: "team", label: "Team members", hint: "4 of 5 seats used", icon: IconTeam },
{ id: "settings", label: "Account settings", hint: "Security & preferences", icon: IconSettings },
];
const STATUS_OFFSET = NAV.length;
const SIGNOUT_INDEX = NAV.length + STATUSES.length;
const TOTAL = SIGNOUT_INDEX + 1;
const ITEM_LABELS: string[] = [
...NAV.map((n) => n.label),
...STATUSES.map((s) => s.label),
"Sign out",
];
export default function MenuUser() {
const reduce = useReducedMotion();
const uid = useId();
const buttonId = `${uid}-btn`;
const menuId = `${uid}-menu`;
const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState(0);
const [status, setStatus] = useState<StatusId>("active");
const [announce, setAnnounce] = useState("");
const wrapperRef = useRef<HTMLDivElement | null>(null);
const buttonRef = useRef<HTMLButtonElement | null>(null);
const itemsRef = useRef<(HTMLButtonElement | null)[]>([]);
const typeRef = useRef<string>("");
const typeTimer = useRef<number | null>(null);
const announceTimer = useRef<number | null>(null);
const current = STATUSES.find((s) => s.id === status) ?? STATUSES[0];
const closeMenu = useCallback((returnFocus: boolean) => {
setOpen(false);
if (returnFocus) {
window.requestAnimationFrame(() => buttonRef.current?.focus());
}
}, []);
const openMenu = useCallback((index: number) => {
setActiveIndex(index);
setOpen(true);
}, []);
const flash = useCallback((message: string) => {
setAnnounce(message);
if (announceTimer.current) window.clearTimeout(announceTimer.current);
announceTimer.current = window.setTimeout(() => setAnnounce(""), 2600);
}, []);
// Focus the active item whenever it changes while open (roving tabindex).
useEffect(() => {
if (!open) return;
itemsRef.current[activeIndex]?.focus();
}, [open, activeIndex]);
// Close on outside pointer press.
useEffect(() => {
if (!open) return;
function onDown(event: MouseEvent) {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
setOpen(false);
}
}
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
useEffect(() => {
return () => {
if (typeTimer.current) window.clearTimeout(typeTimer.current);
if (announceTimer.current) window.clearTimeout(announceTimer.current);
};
}, []);
const typeahead = useCallback((key: string) => {
if (typeTimer.current) window.clearTimeout(typeTimer.current);
typeRef.current += key.toLowerCase();
const buffer = typeRef.current;
for (let step = 1; step <= TOTAL; step += 1) {
const idx = (activeIndex + step) % TOTAL;
if (ITEM_LABELS[idx].toLowerCase().startsWith(buffer)) {
setActiveIndex(idx);
break;
}
}
typeTimer.current = window.setTimeout(() => {
typeRef.current = "";
}, 600);
}, [activeIndex]);
const onButtonKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
if (event.key === "ArrowDown" || event.key === "Enter" || event.key === " ") {
event.preventDefault();
openMenu(0);
} else if (event.key === "ArrowUp") {
event.preventDefault();
openMenu(TOTAL - 1);
}
};
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
switch (event.key) {
case "ArrowDown":
event.preventDefault();
setActiveIndex((i) => (i + 1) % TOTAL);
break;
case "ArrowUp":
event.preventDefault();
setActiveIndex((i) => (i - 1 + TOTAL) % TOTAL);
break;
case "Home":
event.preventDefault();
setActiveIndex(0);
break;
case "End":
event.preventDefault();
setActiveIndex(TOTAL - 1);
break;
case "Escape":
event.preventDefault();
closeMenu(true);
break;
case "Tab":
event.preventDefault();
closeMenu(true);
break;
default:
if (event.key.length === 1 && /\S/.test(event.key)) {
typeahead(event.key);
}
}
};
const handleNav = (item: NavItem) => {
flash(`Opening ${item.label}`);
closeMenu(true);
};
const handleStatus = (next: StatusMeta, index: number) => {
setStatus(next.id);
setActiveIndex(index);
flash(`Status set to ${next.label}`);
};
const handleSignOut = () => {
flash("Signed out of Lumen");
closeMenu(true);
};
const registerItem = (index: number) => (el: HTMLButtonElement | null) => {
itemsRef.current[index] = el;
};
const ringBase =
"outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-indigo-500 dark:focus-visible:ring-indigo-400 ring-offset-white dark:ring-offset-zinc-900";
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-zinc-900 sm:px-8 md:py-28 dark:bg-zinc-950 dark:text-zinc-100">
<style>{`
@keyframes muser-dot-pulse {
0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.55); }
70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); }
100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); }
}
@keyframes muser-rise {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
.muser-pulse { animation: muser-dot-pulse 2.4s ease-out infinite; }
.muser-toast { animation: muser-rise 0.32s cubic-bezier(0.16, 1, 0.3, 1) both; }
@media (prefers-reduced-motion: reduce) {
.muser-pulse, .muser-toast { animation: none !important; }
}
`}</style>
{/* Ambient backdrop */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
<div className="absolute -left-24 top-0 h-72 w-72 rounded-full bg-indigo-300/25 blur-3xl dark:bg-indigo-500/10" />
<div className="absolute -right-16 bottom-0 h-72 w-72 rounded-full bg-violet-300/20 blur-3xl dark:bg-violet-500/10" />
</div>
<div className="relative mx-auto w-full max-w-4xl">
<p className="mb-3 text-xs font-semibold uppercase tracking-[0.2em] text-indigo-600 dark:text-indigo-400">
Lumen workspace
</p>
<h2 className="max-w-xl text-2xl font-semibold tracking-tight text-zinc-900 sm:text-3xl dark:text-zinc-50">
Account menu
</h2>
<p className="mt-2 max-w-md text-sm text-zinc-500 dark:text-zinc-400">
Open the avatar to switch presence, jump to settings, or sign out. Arrow keys, Home/End, type-ahead and Escape all work.
</p>
{/* Mock application bar */}
<div className="mt-10 flex items-center justify-between rounded-2xl border border-zinc-200/80 bg-white/80 px-4 py-3 shadow-[0_1px_0_rgba(0,0,0,0.02),0_18px_40px_-24px_rgba(24,24,27,0.35)] backdrop-blur-sm sm:px-5 dark:border-zinc-800 dark:bg-zinc-900/70">
<div className="flex items-center gap-3">
<span className="grid h-8 w-8 place-items-center rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 text-white shadow-sm">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} aria-hidden="true" className="h-4 w-4">
<path d="M4 14 12 4l8 10M7 20l5-6 5 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className="text-sm font-semibold tracking-tight text-zinc-800 dark:text-zinc-100">Lumen</span>
<nav className="ml-2 hidden items-center gap-1 sm:flex" aria-label="Primary">
{["Overview", "Projects", "Reports"].map((link, i) => (
<button
key={link}
type="button"
className={`rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors ${ringBase} ${
i === 0
? "text-zinc-900 dark:text-zinc-50"
: "text-zinc-500 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200"
}`}
>
{link}
</button>
))}
</nav>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
aria-label="Notifications"
className={`relative grid h-9 w-9 place-items-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-800 dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 ${ringBase}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<path d="M6 9a6 6 0 0 1 12 0c0 5 2 6 2 6H4s2-1 2-6" />
<path d="M10.5 19a1.7 1.7 0 0 0 3 0" />
</svg>
<span className="absolute right-2 top-2 h-1.5 w-1.5 rounded-full bg-rose-500 ring-2 ring-white dark:ring-zinc-900" />
</button>
{/* Account menu */}
<div ref={wrapperRef} className="relative">
<button
ref={buttonRef}
id={buttonId}
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onClick={() => (open ? closeMenu(false) : openMenu(0))}
onKeyDown={onButtonKeyDown}
className={`flex items-center gap-2 rounded-full py-1 pl-1 pr-2 transition-colors hover:bg-zinc-100 dark:hover:bg-zinc-800 ${ringBase} ${
open ? "bg-zinc-100 dark:bg-zinc-800" : ""
}`}
>
<span className="relative">
<span className="grid h-9 w-9 place-items-center rounded-full bg-gradient-to-br from-indigo-500 via-violet-500 to-fuchsia-500 text-sm font-semibold text-white shadow-sm">
PR
</span>
<span
className={`absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full ring-2 ring-white dark:ring-zinc-900 ${current.dot} ${
status === "active" && !reduce ? "muser-pulse" : ""
}`}
/>
</span>
<span className="hidden text-left sm:block">
<span className="block text-sm font-medium leading-4 text-zinc-800 dark:text-zinc-100">Priya</span>
<span className={`block text-[11px] leading-4 ${current.text}`}>{current.label}</span>
</span>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={`h-4 w-4 text-zinc-400 transition-transform duration-200 ${open ? "rotate-180" : ""}`}
>
<path d="m6 9 6 6 6-6" />
</svg>
</button>
<AnimatePresence>
{open && (
<motion.div
key="menu"
id={menuId}
role="menu"
aria-labelledby={buttonId}
aria-orientation="vertical"
onKeyDown={onMenuKeyDown}
initial={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: reduce ? 0 : -6, scale: reduce ? 1 : 0.98 }}
transition={{ duration: reduce ? 0 : 0.16, ease: [0.16, 1, 0.3, 1] }}
className="absolute right-0 z-20 mt-2 w-72 origin-top-right overflow-hidden rounded-2xl border border-zinc-200 bg-white p-1.5 shadow-[0_24px_60px_-20px_rgba(24,24,27,0.4)] dark:border-zinc-800 dark:bg-zinc-900"
>
{/* Identity header */}
<div className="flex items-center gap-3 rounded-xl bg-zinc-50 px-3 py-3 dark:bg-zinc-800/50">
<span className="grid h-10 w-10 shrink-0 place-items-center rounded-full bg-gradient-to-br from-indigo-500 via-violet-500 to-fuchsia-500 text-sm font-semibold text-white">
PR
</span>
<div className="min-w-0">
<p className="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-50">Priya Raman</p>
<p className="truncate text-xs text-zinc-500 dark:text-zinc-400">priya@lumen.design</p>
</div>
<span className="ml-auto shrink-0 rounded-full border border-indigo-200 bg-indigo-50 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-indigo-700 dark:border-indigo-500/30 dark:bg-indigo-500/10 dark:text-indigo-300">
Team
</span>
</div>
{/* Navigation group */}
<div className="py-1.5" role="none">
{NAV.map((item, i) => (
<button
key={item.id}
ref={registerItem(i)}
type="button"
role="menuitem"
tabIndex={activeIndex === i ? 0 : -1}
onClick={() => handleNav(item)}
onMouseEnter={() => setActiveIndex(i)}
className={`group flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${ringBase} ${
activeIndex === i ? "bg-zinc-100 dark:bg-zinc-800" : ""
}`}
>
<span className="text-zinc-500 transition-colors group-hover:text-indigo-600 dark:text-zinc-400 dark:group-hover:text-indigo-400">
{item.icon}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-zinc-800 dark:text-zinc-100">{item.label}</span>
<span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">{item.hint}</span>
</span>
</button>
))}
</div>
<div className="mx-3 my-1 border-t border-zinc-200 dark:border-zinc-800" role="none" />
{/* Presence radiogroup */}
<div className="py-1.5" role="group" aria-label="Set your presence">
<p className="px-3 pb-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-zinc-400 dark:text-zinc-500">
Presence
</p>
{STATUSES.map((s, i) => {
const index = STATUS_OFFSET + i;
const checked = status === s.id;
return (
<button
key={s.id}
ref={registerItem(index)}
type="button"
role="menuitemradio"
aria-checked={checked}
tabIndex={activeIndex === index ? 0 : -1}
onClick={() => handleStatus(s, index)}
onMouseEnter={() => setActiveIndex(index)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left transition-colors ${ringBase} ${
activeIndex === index ? "bg-zinc-100 dark:bg-zinc-800" : ""
}`}
>
<span className={`h-2.5 w-2.5 shrink-0 rounded-full ${s.dot}`} />
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium text-zinc-800 dark:text-zinc-100">{s.label}</span>
<span className="block truncate text-xs text-zinc-500 dark:text-zinc-400">{s.hint}</span>
</span>
{checked && (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4 text-indigo-600 dark:text-indigo-400">
<path d="m5 12.5 4.5 4.5L19 6.5" />
</svg>
)}
</button>
);
})}
</div>
<div className="mx-3 my-1 border-t border-zinc-200 dark:border-zinc-800" role="none" />
{/* Sign out */}
<div className="py-1.5" role="none">
<button
ref={registerItem(SIGNOUT_INDEX)}
type="button"
role="menuitem"
tabIndex={activeIndex === SIGNOUT_INDEX ? 0 : -1}
onClick={handleSignOut}
onMouseEnter={() => setActiveIndex(SIGNOUT_INDEX)}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-rose-600 transition-colors dark:text-rose-400 ${ringBase} ${
activeIndex === SIGNOUT_INDEX ? "bg-rose-50 dark:bg-rose-500/10" : ""
}`}
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-5 w-5">
<path d="M15 12H4.5m0 0 3-3m-3 3 3 3" />
<path d="M10 5.5V5a2 2 0 0 1 2-2h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5a2 2 0 0 1-2-2v-.5" />
</svg>
<span className="text-sm font-medium">Sign out</span>
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
{/* Live feedback */}
<div className="mt-6 h-8">
{announce && (
<div className="muser-toast inline-flex items-center gap-2 rounded-full border border-zinc-200 bg-white px-3.5 py-1.5 text-sm text-zinc-700 shadow-sm dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-200">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
{announce}
</div>
)}
</div>
<p aria-live="polite" className="sr-only">
{announce}
</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 Menu
Originalbasic dropdown menu with items
Dropdown Icons Menu
Originaldropdown with icons and shortcuts

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

