Web InnoventixFreeCode

User Menu

Original · free

user account dropdown with avatar

byWeb InnoventixReact + Tailwind
menuusermenus
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-user.json
menu-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 quote

Similar components

Browse all →