# User menus

> Three account menus — an avatar dropdown with workspace switcher and theme row, a sidebar footer menu that opens upward, and a searchable command-style profile menu — each springing from its trigger.

- Collection: Examples
- Tier: free
- Docs: https://geturui.com/docs/user-menus
- Install: `npx shadcn@latest add @geturui/user-menus`
- Dependencies: motion, lucide-react

## Variants

### Avatar dropdown

Top-bar avatar menu with a workspace switcher whose check glides between rows, a light/dark/system row and shortcut hints.

Install: `npx shadcn@latest add @geturui/user-menus-avatar-dropdown`

```tsx
import { AvatarDropdown } from "@/components/examples/user-menus/avatar-dropdown";

export function Topbar() {
  return <AvatarDropdown onSelect={(action) => route(action)} onThemeChange={(t) => setTheme(t)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| name / email | `string` | — | Shown in the trigger and menu header. |
| accounts | `{ id: string; name: string; plan: string }[]` | — | Workspaces to switch between. |
| defaultAccountId | `string` | — | Initially active workspace. |
| defaultTheme | `"light" | "dark" | "system"` | "system" | Local theme choice; nothing global changes. |
| onSelect | `(action: string) => void` | — | "profile", "billing", "settings", "add-account" or "sign-out". |
| onAccountChange / onThemeChange | `(value) => void` | — | Fire when a radio row changes. |
| align | `"start" | "end"` | "end" | Which edge the panel lines up with. |
| defaultOpen | `boolean` | false | Render open without moving focus. |
| className | `string` | — | Extra classes on the root element. |

### Sidebar footer

User button for the bottom of a sidebar; the menu opens upward with a seat meter, and ArrowUp opens it on the last item.

Install: `npx shadcn@latest add @geturui/user-menus-sidebar-footer`

```tsx
import { SidebarUserMenu } from "@/components/examples/user-menus/sidebar-footer";

export function SidebarFooter() {
  return <SidebarUserMenu name="Jordan Okafor" seatsUsed={8} seatsTotal={10} onSelect={(id) => route(id)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| name / email / plan | `string` | — | Identity and plan label. |
| seatsUsed / seatsTotal | `number` | 8 / 10 | Drives the seat meter. |
| items | `{ id; label; icon?; badge? }[]` | — | Menu rows between the meter and Log out. |
| onSelect | `(id: string) => void` | — | Item id, "upgrade" or "sign-out". |
| defaultOpen | `boolean` | false | Render open without moving focus. |
| className | `string` | — | Extra classes on the root element. |

### Command profile

Profile menu with a search field: type to filter grouped actions, a highlight slides between rows and every action shows its shortcut.

Install: `npx shadcn@latest add @geturui/user-menus-command-profile`

```tsx
import { CommandProfileMenu } from "@/components/examples/user-menus/command-profile";

export function ProfileButton() {
  return <CommandProfileMenu name="Priya Raman" handle="@priya" onSelect={(a) => run(a.id)} />;
}
```

| Prop | Type | Default | Description |
|---|---|---|---|
| name / handle | `string` | — | Shown in the panel header; initials fill the avatar. |
| actions | `{ id; label; group; icon?; keys? }[]` | — | Grouped, filterable actions. |
| onSelect | `(action: CommandProfileAction) => void` | — | Click or Enter on an action. |
| defaultOpen | `boolean` | false | Render open without moving focus. |
| className | `string` | — | Extra classes on the root element. |

## Accessibility

- Menus use roving focus: arrows move, Home and End jump, Enter activates, Escape closes and returns focus to the trigger, Tab closes.
- Account and theme choices are menuitemradio items with aria-checked; the command menu is a combobox driving a listbox through aria-activedescendant.
- Panels only fade when reduced motion is requested.

## Source

### components/examples/user-menus/avatar-dropdown.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronDown, CreditCard, LogOut, Monitor, Moon, Plus, Settings, Sun, UserRound } from "lucide-react";
import { cn } from "@/lib/utils";

export interface MenuAccount {
  id: string;
  name: string;
  plan: string;
}

export type MenuTheme = "light" | "dark" | "system";

export interface AvatarDropdownProps {
  name?: string;
  email?: string;
  accounts?: MenuAccount[];
  defaultAccountId?: string;
  defaultTheme?: MenuTheme;
  /** Called with "profile", "billing", "settings", "add-account" or "sign-out". */
  onSelect?: (action: string) => void;
  onAccountChange?: (account: MenuAccount) => void;
  /** Local preference only; wire it to your theme provider. */
  onThemeChange?: (theme: MenuTheme) => void;
  align?: "start" | "end";
  /** Render open on mount (without moving focus), e.g. for previews. */
  defaultOpen?: boolean;
  className?: string;
}

const ACCOUNTS: MenuAccount[] = [
  { id: "northwind", name: "Northwind Studio", plan: "Pro" },
  { id: "halcyon", name: "Halcyon Labs", plan: "Team" },
  { id: "personal", name: "Personal", plan: "Free" },
];
const THEMES = [
  { id: "light", label: "Light", Icon: Sun },
  { id: "dark", label: "Dark", Icon: Moon },
  { id: "system", label: "System", Icon: Monitor },
] as const;
const ITEMS = [
  { id: "profile", label: "Profile", Icon: UserRound, kbd: "⇧P" },
  { id: "billing", label: "Billing", Icon: CreditCard, kbd: "⇧B" },
  { id: "settings", label: "Settings", Icon: Settings, kbd: "⌘," },
];
const spring = { type: "spring", stiffness: 420, damping: 32 } as const;
const initials = (n: string) => n.split(" ").map((p) => p[0]).slice(0, 2).join("");

/** Avatar dropdown with an account switcher, a theme row and roving keyboard focus; the panel springs from the trigger corner. */
export function AvatarDropdown({
  name = "Maya Kerrigan",
  email = "maya@northwind.studio",
  accounts = ACCOUNTS,
  defaultAccountId = "northwind",
  defaultTheme = "system",
  onSelect,
  onAccountChange,
  onThemeChange,
  align = "end",
  defaultOpen = false,
  className,
}: AvatarDropdownProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const [open, setOpen] = React.useState(defaultOpen);
  const focusOnOpen = React.useRef(false);
  const [account, setAccount] = React.useState(defaultAccountId);
  const [theme, setTheme] = React.useState<MenuTheme>(defaultTheme);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const menuRef = React.useRef<HTMLDivElement>(null);
  const current = accounts.find((a) => a.id === account) ?? accounts[0];

  const items = () => Array.from(menuRef.current?.querySelectorAll<HTMLElement>("[data-item]") ?? []);

  React.useEffect(() => {
    if (!open) return;
    if (focusOnOpen.current) requestAnimationFrame(() => menuRef.current?.querySelector<HTMLElement>("[data-item]")?.focus());
    const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setOpen(false);
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  function close(refocus = true) {
    setOpen(false);
    if (refocus) triggerRef.current?.focus();
  }

  function onKey(e: React.KeyboardEvent) {
    const list = items();
    const i = list.indexOf(document.activeElement as HTMLElement);
    const go = (n: number) => list[(n + list.length) % list.length]?.focus();
    if (e.key === "ArrowDown") go(i + 1);
    else if (e.key === "ArrowUp") go(i - 1);
    else if (e.key === "Home") go(0);
    else if (e.key === "End") go(list.length - 1);
    else if (e.key === "Escape") close();
    else if (e.key === "Tab") return close(false);
    else return;
    e.preventDefault();
  }

  function act(id: string) {
    onSelect?.(id);
    close();
  }

  const row = "flex w-full items-center gap-2.5 rounded-sm px-2.5 py-2 text-left text-[13px] text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover";

  return (
    <div ref={rootRef} className={cn("relative inline-block", className)}>
      <motion.button
        ref={triggerRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={`${uid}-menu`}
        whileTap={{ scale: 0.96 }}
        onClick={() => {
          focusOnOpen.current = true;
          setOpen((o) => !o);
        }}
        onKeyDown={(e) => {
          if (e.key !== "ArrowDown") return;
          e.preventDefault();
          focusOnOpen.current = true;
          setOpen(true);
        }}
        className="flex items-center gap-2 rounded-full border border-border bg-surface-raised py-1 pl-1 pr-2.5 shadow-sm outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]"
      >
        <span aria-hidden className="grid size-7 place-items-center rounded-full bg-ink text-[11px] font-semibold text-bg">{initials(name)}</span>
        <span className="text-[13px] font-medium text-ink">{name.split(" ")[0]}</span>
        <motion.span aria-hidden animate={{ rotate: open ? 180 : 0 }} transition={spring} className="text-ink-subtle">
          <ChevronDown className="size-3.5" />
        </motion.span>
      </motion.button>

      <AnimatePresence>
        {open && (
          <motion.div
            ref={menuRef}
            id={`${uid}-menu`}
            role="menu"
            aria-label="Account"
            onKeyDown={onKey}
            initial={{ opacity: 0, scale: reduce ? 1 : 0.94, y: reduce ? 0 : -6 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: reduce ? 1 : 0.96, y: reduce ? 0 : -4, transition: { duration: 0.12 } }}
            transition={spring}
            style={{ transformOrigin: align === "end" ? "top right" : "top left" }}
            className={cn("absolute top-full z-30 mt-2 w-[min(288px,calc(100vw-32px))] rounded-md border border-border bg-surface-raised p-1.5 shadow-lg", align === "end" ? "right-0" : "left-0")}
          >
            <div className="px-2.5 pb-2 pt-1.5">
              <p className="text-[13.5px] font-semibold text-ink">{name}</p>
              <p className="truncate text-[12px] text-ink-subtle">{email}</p>
            </div>
            <div className="h-px bg-border" />
            <p className="px-2.5 pb-1 pt-2 text-[11px] font-medium text-ink-subtle">Workspaces</p>
            <div role="group" aria-label="Workspaces">
              {accounts.map((a) => (
                <button
                  key={a.id}
                  type="button"
                  role="menuitemradio"
                  aria-checked={a.id === account}
                  data-item
                  tabIndex={-1}
                  onClick={() => {
                    setAccount(a.id);
                    onAccountChange?.(a);
                  }}
                  className={row}
                >
                  <span aria-hidden className="grid size-6 place-items-center rounded-xs bg-surface-sunken text-[10.5px] font-semibold text-ink-muted ring-1 ring-border">{initials(a.name)}</span>
                  <span className="flex-1 truncate">{a.name}</span>
                  <span className="text-[11px] text-ink-subtle">{a.plan}</span>
                  <span className="grid size-4 place-items-center">
                    {a.id === current.id && (
                      <motion.span layoutId={`${uid}-check`} transition={spring}>
                        <Check aria-hidden className="size-4 text-volt-ink" />
                      </motion.span>
                    )}
                  </span>
                </button>
              ))}
              <button type="button" role="menuitem" data-item tabIndex={-1} onClick={() => act("add-account")} className={cn(row, "text-ink-muted")}>
                <span aria-hidden className="grid size-6 place-items-center rounded-xs border border-dashed border-border-strong"><Plus className="size-3.5" /></span>
                Add workspace
              </button>
            </div>
            <div className="my-1.5 h-px bg-border" />
            {ITEMS.map(({ id, label, Icon, kbd }) => (
              <button key={id} type="button" role="menuitem" data-item tabIndex={-1} onClick={() => act(id)} className={row}>
                <Icon aria-hidden className="size-4 text-ink-subtle" />
                <span className="flex-1">{label}</span>
                <kbd className="font-mono text-[11px] text-ink-subtle">{kbd}</kbd>
              </button>
            ))}
            <div className="flex items-center justify-between gap-2 px-2.5 py-1.5">
              <span className="text-[13px] text-ink">Theme</span>
              <div role="group" aria-label="Theme" className="flex rounded-full border border-border bg-surface-sunken p-0.5">
                {THEMES.map(({ id, label, Icon }) => (
                  <button
                    key={id}
                    type="button"
                    role="menuitemradio"
                    aria-checked={theme === id}
                    aria-label={label}
                    data-item
                    tabIndex={-1}
                    onClick={() => {
                      setTheme(id);
                      onThemeChange?.(id);
                    }}
                    className="relative grid size-7 place-items-center rounded-full outline-none focus-visible:shadow-[var(--focus-ring)]"
                  >
                    {theme === id && <motion.span layoutId={`${uid}-theme`} transition={spring} className="absolute inset-0 rounded-full bg-surface-raised shadow-sm ring-1 ring-border" />}
                    <Icon aria-hidden className={cn("relative size-3.5", theme === id ? "text-ink" : "text-ink-subtle")} />
                  </button>
                ))}
              </div>
            </div>
            <div className="my-1.5 h-px bg-border" />
            <button type="button" role="menuitem" data-item tabIndex={-1} onClick={() => act("sign-out")} className={row}>
              <LogOut aria-hidden className="size-4 text-ink-subtle" />
              Sign out
            </button>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

```

### components/examples/user-menus/sidebar-footer.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Bell, BookOpen, ChevronsUpDown, Keyboard, LogOut, Sparkles, UserRound } from "lucide-react";
import { cn } from "@/lib/utils";

export interface SidebarUserMenuItem {
  id: string;
  label: string;
  icon?: React.ComponentType<{ className?: string }>;
  /** Small count shown at the end of the row. */
  badge?: number;
}

export interface SidebarUserMenuProps {
  name?: string;
  email?: string;
  plan?: string;
  seatsUsed?: number;
  seatsTotal?: number;
  items?: SidebarUserMenuItem[];
  /** Called with the item id, "upgrade" or "sign-out". */
  onSelect?: (id: string) => void;
  /** Render open on mount (without moving focus), e.g. for previews. */
  defaultOpen?: boolean;
  className?: string;
}

const ITEMS: SidebarUserMenuItem[] = [
  { id: "account", label: "Account", icon: UserRound },
  { id: "notifications", label: "Notifications", icon: Bell, badge: 3 },
  { id: "shortcuts", label: "Keyboard shortcuts", icon: Keyboard },
  { id: "docs", label: "Help and docs", icon: BookOpen },
];
const spring = { type: "spring", stiffness: 380, damping: 30 } as const;

/** Sidebar footer user button whose menu opens upward with a seat meter; ArrowUp opens it focused on the last item. */
export function SidebarUserMenu({
  name = "Jordan Okafor",
  email = "jordan@relaybase.io",
  plan = "Team plan",
  seatsUsed = 8,
  seatsTotal = 10,
  items = ITEMS,
  onSelect,
  defaultOpen = false,
  className,
}: SidebarUserMenuProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const [open, setOpen] = React.useState(defaultOpen);
  const focusOnOpen = React.useRef(false);
  const startAt = React.useRef<"first" | "last">("first");
  const rootRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const menuRef = React.useRef<HTMLDivElement>(null);
  const initials = name.split(" ").map((p) => p[0]).slice(0, 2).join("");

  React.useEffect(() => {
    if (!open) return;
    if (focusOnOpen.current)
      requestAnimationFrame(() => {
        const list = menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]');
        if (list?.length) list[startAt.current === "last" ? list.length - 1 : 0].focus();
      });
    const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setOpen(false);
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  function openAt(where: "first" | "last") {
    startAt.current = where;
    focusOnOpen.current = true;
    setOpen(true);
  }

  function close(refocus = true) {
    setOpen(false);
    if (refocus) triggerRef.current?.focus();
  }

  function onKey(e: React.KeyboardEvent) {
    const list = Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]') ?? []);
    const i = list.indexOf(document.activeElement as HTMLElement);
    const go = (n: number) => list[(n + list.length) % list.length]?.focus();
    if (e.key === "ArrowDown") go(i + 1);
    else if (e.key === "ArrowUp") go(i - 1);
    else if (e.key === "Home") go(0);
    else if (e.key === "End") go(list.length - 1);
    else if (e.key === "Escape") close();
    else if (e.key === "Tab") return close(false);
    else return;
    e.preventDefault();
  }

  function act(id: string) {
    onSelect?.(id);
    close();
  }

  const row = "flex w-full items-center gap-2.5 rounded-sm px-2.5 py-2 text-left text-[13px] text-ink outline-none transition-colors hover:bg-surface-hover focus-visible:bg-surface-hover";
  const pct = Math.min(100, (seatsUsed / seatsTotal) * 100);

  return (
    <div ref={rootRef} className={cn("relative w-full max-w-[260px]", className)}>
      <AnimatePresence>
        {open && (
          <motion.div
            ref={menuRef}
            id={`${uid}-menu`}
            role="menu"
            aria-label={`${name} account`}
            onKeyDown={onKey}
            initial={{ opacity: 0, y: reduce ? 0 : 10, scale: reduce ? 1 : 0.97 }}
            animate={{ opacity: 1, y: 0, scale: 1 }}
            exit={{ opacity: 0, y: reduce ? 0 : 6, scale: reduce ? 1 : 0.98, transition: { duration: 0.12 } }}
            transition={spring}
            style={{ transformOrigin: "bottom center" }}
            className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-md border border-border bg-surface-raised p-1.5 shadow-lg"
          >
            <div className="rounded-sm bg-surface-sunken p-2.5">
              <div className="flex items-center justify-between text-[12px]">
                <span className="font-medium text-ink">{plan}</span>
                <span className="tabular-nums text-ink-subtle">{seatsUsed} of {seatsTotal} seats</span>
              </div>
              <div role="meter" aria-label="Seats used" aria-valuemin={0} aria-valuemax={seatsTotal} aria-valuenow={seatsUsed} className="mt-2 h-1.5 overflow-hidden rounded-full bg-border">
                <motion.div initial={{ scaleX: 0 }} animate={{ scaleX: pct / 100 }} transition={{ ...spring, delay: 0.08 }} style={{ transformOrigin: "left" }} className="h-full rounded-full bg-volt" />
              </div>
              <button type="button" role="menuitem" tabIndex={-1} onClick={() => act("upgrade")} className="mt-2.5 inline-flex items-center gap-1.5 rounded-xs text-[12px] font-medium text-volt-ink outline-none hover:underline focus-visible:shadow-[var(--focus-ring)]">
                <Sparkles aria-hidden className="size-3.5" />
                Add more seats
              </button>
            </div>
            <div className="mt-1.5 space-y-0.5">
              {items.map(({ id, label, icon: Icon, badge }) => (
                <button key={id} type="button" role="menuitem" tabIndex={-1} onClick={() => act(id)} className={row}>
                  {Icon && <Icon className="size-4 text-ink-subtle" />}
                  <span className="flex-1">{label}</span>
                  {badge ? <span className="rounded-full bg-surface-sunken px-1.5 text-[11px] font-medium tabular-nums text-ink-muted ring-1 ring-border">{badge}</span> : null}
                </button>
              ))}
            </div>
            <div className="my-1.5 h-px bg-border" />
            <button type="button" role="menuitem" tabIndex={-1} onClick={() => act("sign-out")} className={row}>
              <LogOut aria-hidden className="size-4 text-ink-subtle" />
              Log out
            </button>
          </motion.div>
        )}
      </AnimatePresence>

      <motion.button
        ref={triggerRef}
        type="button"
        aria-haspopup="menu"
        aria-expanded={open}
        aria-controls={`${uid}-menu`}
        whileTap={{ scale: 0.98 }}
        onClick={() => (open ? close() : openAt("first"))}
        onKeyDown={(e) => {
          if (e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
          e.preventDefault();
          openAt(e.key === "ArrowUp" ? "last" : "first");
        }}
        className={cn(
          "flex w-full items-center gap-2.5 rounded-md p-2 text-left outline-none transition-colors hover:bg-surface-hover focus-visible:shadow-[var(--focus-ring)]",
          open && "bg-surface-hover",
        )}
      >
        <span aria-hidden className="relative grid size-9 shrink-0 place-items-center rounded-full bg-ink text-[12px] font-semibold text-bg">
          {initials}
          <span className="absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-surface-raised bg-success" />
        </span>
        <span className="min-w-0 flex-1">
          <span className="block truncate text-[13px] font-medium text-ink">{name}</span>
          <span className="block truncate text-[11.5px] text-ink-subtle">{email}</span>
        </span>
        <ChevronsUpDown aria-hidden className="size-4 shrink-0 text-ink-subtle" />
      </motion.button>
    </div>
  );
}

```

### components/examples/user-menus/command-profile.tsx

```tsx
"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CircleDot, CreditCard, LogOut, Moon, Search, Settings, UserRound, UsersRound } from "lucide-react";
import { cn } from "@/lib/utils";

export interface CommandProfileAction {
  id: string;
  label: string;
  group: string;
  icon?: React.ComponentType<{ className?: string }>;
  /** Keys shown on the right, e.g. ["G", "P"]. */
  keys?: string[];
}

export interface CommandProfileMenuProps {
  name?: string;
  handle?: string;
  actions?: CommandProfileAction[];
  onSelect?: (action: CommandProfileAction) => void;
  /** Render open on mount (without moving focus), e.g. for previews. */
  defaultOpen?: boolean;
  className?: string;
}

const ACTIONS: CommandProfileAction[] = [
  { id: "profile", label: "View profile", group: "Account", icon: UserRound, keys: ["G", "P"] },
  { id: "status", label: "Set status", group: "Account", icon: CircleDot, keys: ["S"] },
  { id: "dnd", label: "Pause notifications", group: "Account", icon: Moon, keys: ["⇧", "D"] },
  { id: "team", label: "Invite teammates", group: "Workspace", icon: UsersRound, keys: ["I"] },
  { id: "billing", label: "Billing and usage", group: "Workspace", icon: CreditCard, keys: ["G", "B"] },
  { id: "settings", label: "Settings", group: "Workspace", icon: Settings, keys: ["⌘", ","] },
  { id: "sign-out", label: "Sign out", group: "Session", icon: LogOut, keys: ["⇧", "Q"] },
];
const spring = { type: "spring", stiffness: 480, damping: 36 } as const;

/** Command-style profile menu: type to filter, arrows move a sliding highlight, Enter runs, Escape clears then closes. */
export function CommandProfileMenu({ name = "Priya Raman", handle = "@priya", actions = ACTIONS, onSelect, defaultOpen = false, className }: CommandProfileMenuProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const [open, setOpen] = React.useState(defaultOpen);
  const focusOnOpen = React.useRef(false);
  const [query, setQuery] = React.useState("");
  const [active, setActive] = React.useState(0);
  const [ran, setRan] = React.useState<string | null>(null);
  const rootRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);
  const inputRef = React.useRef<HTMLInputElement>(null);
  const initials = name.split(" ").map((p) => p[0]).slice(0, 2).join("");

  const results = actions.filter((a) => a.label.toLowerCase().includes(query.trim().toLowerCase()));
  const groups = [...new Set(results.map((a) => a.group))];

  React.useEffect(() => {
    if (!open) return;
    if (focusOnOpen.current) requestAnimationFrame(() => inputRef.current?.focus());
    const onDown = (e: PointerEvent) => !rootRef.current?.contains(e.target as Node) && setOpen(false);
    document.addEventListener("pointerdown", onDown);
    return () => document.removeEventListener("pointerdown", onDown);
  }, [open]);

  function close() {
    setOpen(false);
    setQuery("");
    setActive(0);
    triggerRef.current?.focus();
  }

  function run(a: CommandProfileAction | undefined) {
    if (!a) return;
    onSelect?.(a);
    setRan(a.label);
    close();
  }

  function onKey(e: React.KeyboardEvent) {
    const n = results.length;
    if (e.key === "ArrowDown") setActive((i) => (i + 1) % Math.max(n, 1));
    else if (e.key === "ArrowUp") setActive((i) => (i - 1 + n) % Math.max(n, 1));
    else if (e.key === "Home") setActive(0);
    else if (e.key === "End") setActive(n - 1);
    else if (e.key === "Enter") run(results[active]);
    else if (e.key === "Escape" && query) {
      setQuery("");
      setActive(0);
    } else if (e.key === "Escape") close();
    else if (e.key === "Tab") return setOpen(false);
    else return;
    e.preventDefault();
  }

  return (
    <div ref={rootRef} className={cn("relative inline-flex flex-col items-start gap-2", className)}>
      <motion.button
        ref={triggerRef}
        type="button"
        aria-haspopup="dialog"
        aria-expanded={open}
        aria-label={`Open profile menu for ${name}`}
        whileTap={{ scale: 0.94 }}
        onClick={() => {
          if (open) return close();
          focusOnOpen.current = true;
          setOpen(true);
        }}
        className="grid size-10 place-items-center rounded-full bg-ink text-[13px] font-semibold text-bg shadow-sm outline-none ring-2 ring-surface-raised focus-visible:shadow-[var(--focus-ring)]"
      >
        {initials}
      </motion.button>
      <p aria-live="polite" className="sr-only">{ran ? `${ran} selected` : ""}</p>

      <AnimatePresence>
        {open && (
          <motion.div
            role="dialog"
            aria-label="Profile commands"
            initial={{ opacity: 0, y: reduce ? 0 : -8, scale: reduce ? 1 : 0.96, filter: "blur(6px)" }}
            animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }}
            exit={{ opacity: 0, y: reduce ? 0 : -4, scale: reduce ? 1 : 0.98, transition: { duration: 0.12 } }}
            transition={spring}
            style={{ transformOrigin: "top left" }}
            className="absolute left-0 top-12 z-30 w-[min(320px,calc(100vw-32px))] overflow-hidden rounded-md border border-border bg-surface-raised shadow-lg"
          >
            <div className="flex items-center gap-3 border-b border-border p-3">
              <span aria-hidden className="grid size-9 place-items-center rounded-full bg-ink text-[12px] font-semibold text-bg">{initials}</span>
              <div className="min-w-0">
                <p className="truncate text-[13.5px] font-semibold text-ink">{name}</p>
                <p className="text-[12px] text-ink-subtle">{handle}</p>
              </div>
            </div>
            <label className="flex items-center gap-2 border-b border-border px-3">
              <Search aria-hidden className="size-4 text-ink-subtle" />
              <span className="sr-only">Search profile actions</span>
              <input
                ref={inputRef}
                role="combobox"
                aria-expanded
                aria-controls={`${uid}-list`}
                aria-activedescendant={results[active] ? `${uid}-${results[active].id}` : undefined}
                value={query}
                onChange={(e) => {
                  setQuery(e.target.value);
                  setActive(0);
                }}
                onKeyDown={onKey}
                placeholder="Search actions"
                className="h-11 w-full bg-transparent text-[13.5px] text-ink outline-none placeholder:text-ink-subtle"
              />
              <kbd className="rounded-xs border border-border px-1.5 font-mono text-[10.5px] text-ink-subtle">esc</kbd>
            </label>
            <div id={`${uid}-list`} role="listbox" aria-label="Actions" className="max-h-[340px] overflow-y-auto p-1.5">
              {results.length === 0 && <p className="px-2.5 py-6 text-center text-[13px] text-ink-subtle">No actions match “{query}”.</p>}
              {groups.map((g) => (
                <div key={g} role="group" aria-label={g}>
                  <p aria-hidden className="px-2.5 pb-1 pt-2 text-[11px] font-medium text-ink-subtle">{g}</p>
                  {results.filter((a) => a.group === g).map((a) => {
                    const i = results.indexOf(a);
                    const on = i === active;
                    const Icon = a.icon;
                    return (
                      <div
                        key={a.id}
                        id={`${uid}-${a.id}`}
                        role="option"
                        aria-selected={on}
                        onPointerMove={() => setActive(i)}
                        onClick={() => run(a)}
                        className="relative flex cursor-pointer items-center gap-2.5 rounded-sm px-2.5 py-2 text-[13px]"
                      >
                        {on && <motion.span layoutId={`${uid}-hl`} transition={spring} className="absolute inset-0 rounded-sm bg-surface-hover ring-1 ring-border" />}
                        {Icon && <Icon className={cn("relative size-4", on ? "text-ink" : "text-ink-subtle")} />}
                        <span className={cn("relative flex-1", on ? "text-ink" : "text-ink-muted")}>{a.label}</span>
                        <span className="relative flex gap-1">
                          {a.keys?.map((k) => (
                            <kbd key={k} className="grid h-5 min-w-5 place-items-center rounded-xs border border-border bg-surface-sunken px-1 font-mono text-[10.5px] text-ink-subtle">{k}</kbd>
                          ))}
                        </span>
                      </div>
                    );
                  })}
                </div>
              ))}
            </div>
            <div className="flex items-center justify-between border-t border-border bg-surface px-3 py-2 text-[11px] text-ink-subtle">
              <span>↑↓ to move · ↵ to run</span>
              <span>{results.length} actions</span>
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}

```
