{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "user-menu",
  "type": "registry:ui",
  "title": "User Menu",
  "description": "Avatar menu with presence, a sliding theme control and a sign-out that waits.",
  "categories": [
    "app-nav"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/user-menu.tsx",
      "content": "\"use client\";\nimport { Avatar } from \"@base-ui/react/avatar\";\nimport { Menu } from \"@base-ui/react/menu\";\nimport { AnimatePresence, LayoutGroup, animate, motion, useMotionValue, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { ArrowUpRight, ChevronRight, Loader, Monitor, Moon, Sun } from \"@/lib/icons\";\nimport { spring, swap } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Status and avatar\n * -----------------------------------------------------------------------------------------------*/\n\nexport type UserStatus = \"online\" | \"away\" | \"busy\" | \"offline\";\n\nconst statusLabel: Record<UserStatus, string> = {\n  online: \"Active\",\n  away: \"Away\",\n  busy: \"Do not disturb\",\n  offline: \"Invisible\",\n};\n\n/**\n * The presence dot. It morphs between states rather than swapping: busy draws a bar across\n * it, invisible hollows it out, and the color follows. The ring is the surface it sits on.\n */\nexport function StatusDot({ status, size = 10, className }: { status: UserStatus; size?: number; className?: string }) {\n  return (\n    <span\n      aria-hidden\n      data-status={status}\n      style={{ width: size, height: size }}\n      className={cn(\n        \"grid shrink-0 place-items-center rounded-full ring-2 ring-(--avatar-ring,var(--frame))\",\n        \"transition-[background-color] duration-200 ease-out-expo\",\n        \"data-[status=online]:bg-success data-[status=away]:bg-warning data-[status=busy]:bg-danger data-[status=offline]:bg-fg-3\",\n        className,\n      )}\n    >\n      <span\n        className={cn(\n          \"col-start-1 row-start-1 h-[2px] w-[56%] rounded-full bg-(--avatar-ring,var(--frame))\",\n          \"scale-x-0 transition-transform duration-200 ease-out-expo motion-reduce:transition-none\",\n          status === \"busy\" && \"scale-x-100\",\n        )}\n      />\n      <span\n        className={cn(\n          \"col-start-1 row-start-1 size-[44%] rounded-full bg-(--avatar-ring,var(--frame))\",\n          \"scale-0 transition-transform duration-200 ease-out-expo motion-reduce:transition-none\",\n          status === \"offline\" && \"scale-100\",\n        )}\n      />\n    </span>\n  );\n}\n\nexport type UserAvatarProps = {\n  name: string;\n  src?: string;\n  size?: number;\n  status?: UserStatus;\n  className?: string;\n};\n\n/** A round avatar with an initials fallback that matches its size, and an optional presence dot. */\nexport function UserAvatar({ name, src, size = 32, status, className }: UserAvatarProps) {\n  const initials = name\n    .split(/\\s+/)\n    .filter(Boolean)\n    .slice(0, 2)\n    .map((p) => p[0]?.toUpperCase())\n    .join(\"\");\n  return (\n    <span className={cn(\"relative inline-block shrink-0 align-middle\", className)} style={{ width: size, height: size }}>\n      <Avatar.Root className=\"flex size-full select-none items-center justify-center overflow-hidden rounded-full bg-hover text-fg-2 shadow-[inset_0_0_0_1px_var(--line-2)]\">\n        {src && <Avatar.Image src={src} alt=\"\" width={size} height={size} className=\"size-full object-cover\" />}\n        <Avatar.Fallback delay={src ? 400 : 0} className=\"font-medium leading-none tracking-[0.01em]\" style={{ fontSize: Math.round(size * 0.36) }}>\n          {initials}\n        </Avatar.Fallback>\n      </Avatar.Root>\n      {status && <StatusDot status={status} size={Math.max(8, Math.round(size * 0.3))} className=\"absolute -bottom-px -right-px\" />}\n    </span>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * UserMenu\n * -----------------------------------------------------------------------------------------------*/\n\nexport type Theme = \"light\" | \"dark\" | \"system\";\n\nexport type UserMenuProps = {\n  user: { name: string; email: string; image?: string };\n  /** Presence on the avatar. Pass onStatusChange too and a status submenu appears in the menu. */\n  status?: UserStatus;\n  onStatusChange?: (status: UserStatus) => void;\n  /** Adds the theme control. Pair with onThemeChange. */\n  theme?: Theme;\n  onThemeChange?: (theme: Theme) => void;\n  /** Adds Sign out as the last item. Return a promise to keep the menu open with a spinner until it settles. */\n  onSignOut?: () => void | Promise<unknown>;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  side?: \"top\" | \"bottom\" | \"left\" | \"right\";\n  align?: \"start\" | \"center\" | \"end\";\n  /** Avatar size on the trigger. */\n  size?: number;\n  /** Portal container. Defaults to document.body. */\n  container?: HTMLElement | React.RefObject<HTMLElement | null> | null;\n  className?: string;\n  /** Your items (profile, settings, shortcuts), between the header and the theme control. */\n  children?: React.ReactNode;\n};\n\n/**\n * The avatar in the corner that opens everything about the account: who you are,\n * your status, the theme, and signing out.\n */\nexport function UserMenu({\n  user,\n  status,\n  onStatusChange,\n  theme,\n  onThemeChange,\n  onSignOut,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  side = \"bottom\",\n  align = \"end\",\n  size = 32,\n  container,\n  className,\n  children,\n}: UserMenuProps) {\n  const [open, setOpen] = useControllableState({ value: openProp, defaultValue: defaultOpen, onChange: onOpenChange });\n  const [signingOut, setSigningOut] = useState(false);\n  const [failed, setFailed] = useState(false);\n\n  const signOut = async () => {\n    if (!onSignOut || signingOut) return;\n    setFailed(false);\n    const result = onSignOut();\n    if (!(result instanceof Promise)) return setOpen(false);\n    setSigningOut(true);\n    try {\n      await result;\n      setOpen(false);\n    } catch {\n      setFailed(true);\n    } finally {\n      setSigningOut(false);\n    }\n  };\n\n  return (\n    <Menu.Root\n      open={open}\n      onOpenChange={(next) => {\n        setOpen(next);\n        if (!next) setFailed(false);\n      }}\n    >\n      <Menu.Trigger\n        aria-label={`Account: ${user.name}${status ? `, ${statusLabel[status]}` : \"\"}`}\n        className={cn(\n          \"group/avatar relative inline-flex shrink-0 rounded-full outline-none\",\n          \"touch-manipulation [-webkit-tap-highlight-color:transparent]\",\n          // The avatar draws at 32px; the hit area grows to 44px on touch without moving anything.\n          \"before:absolute before:-inset-1.5 before:content-[''] pointer-fine:before:hidden\",\n          \"transition-[scale,box-shadow] duration-150 ease-out active:scale-[0.94] active:duration-75\",\n          \"shadow-[0_0_0_0_var(--line-2)] hover:shadow-[0_0_0_3px_var(--line-2)] data-popup-open:shadow-[0_0_0_3px_var(--fg-4)]\",\n          \"focus-visible:outline-solid focus-visible:outline-1 focus-visible:outline-offset-3 focus-visible:outline-fg-3\",\n          className,\n        )}\n      >\n        <UserAvatar name={user.name} src={user.image} size={size} status={status} />\n      </Menu.Trigger>\n\n      <Menu.Portal container={container}>\n        <Menu.Positioner side={side} align={align} sideOffset={8} collisionPadding={8} className=\"z-(--z-popover) outline-none\">\n          <Popup>\n            <div className=\"flex items-center gap-2.5 px-2 pb-2.5 pt-2\">\n              <UserAvatar name={user.name} src={user.image} size={36} status={status} className=\"[--avatar-ring:var(--raised)]\" />\n              <div className=\"min-w-0 flex-1\">\n                <p className=\"truncate text-[13px] font-medium leading-[18px] tracking-[-0.01em] text-fg\">{user.name}</p>\n                <p className=\"truncate text-[12px] leading-[16px] text-fg-3\">{user.email}</p>\n              </div>\n            </div>\n\n            {status && onStatusChange && (\n              <>\n                <UserMenuSeparator />\n                <StatusSubmenu status={status} onStatusChange={onStatusChange} />\n              </>\n            )}\n\n            {children && (\n              <>\n                <UserMenuSeparator />\n                {children}\n              </>\n            )}\n\n            {theme && onThemeChange && (\n              <>\n                <UserMenuSeparator />\n                <ThemeControl theme={theme} onThemeChange={onThemeChange} />\n              </>\n            )}\n\n            {onSignOut && (\n              <>\n                <UserMenuSeparator />\n                <Menu.Item\n                  closeOnClick={false}\n                  onClick={signOut}\n                  aria-busy={signingOut || undefined}\n                  data-row=\"\"\n                  className={cn(itemClass, failed && \"text-danger\")}\n                >\n                  <span className=\"grid size-4 place-items-center text-fg-3\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      {signingOut ? (\n                        <motion.span key=\"busy\" {...swap} transition={spring.pop} className=\"grid\">\n                          <Loader size={14} className=\"animate-spin\" />\n                        </motion.span>\n                      ) : (\n                        <motion.span key=\"icon\" {...swap} transition={spring.pop} className=\"grid\">\n                          <SignOutIcon />\n                        </motion.span>\n                      )}\n                    </AnimatePresence>\n                  </span>\n                  <span className=\"min-w-0 flex-1 truncate\">{signingOut ? \"Signing out…\" : failed ? \"Couldn’t sign out. Try again\" : \"Sign out\"}</span>\n                </Menu.Item>\n                <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n                  {signingOut ? \"Signing out\" : failed ? \"Couldn’t sign out. Try again.\" : \"\"}\n                </span>\n              </>\n            )}\n          </Popup>\n        </Menu.Positioner>\n      </Menu.Portal>\n    </Menu.Root>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Items\n * -----------------------------------------------------------------------------------------------*/\n\nconst itemClass = cn(\n  \"group/item relative flex h-8 cursor-default select-none items-center gap-2.5 rounded-lg px-2 text-[13px] text-fg-2 outline-none\",\n  \"transition-colors duration-100 data-highlighted:text-fg pointer-coarse:h-10\",\n  \"data-disabled:pointer-events-none data-disabled:text-fg-4\",\n);\n\nexport type UserMenuItemProps = Omit<Menu.Item.Props, \"className\"> & {\n  icon?: React.ReactNode;\n  /** Shown on the right, e.g. \"⌘,\" — display only; bind the key yourself. */\n  shortcut?: string;\n  /** Renders a link. */\n  href?: string;\n  /** Opens in a new tab and shows an arrow. */\n  external?: boolean;\n  className?: string;\n};\n\nexport function UserMenuItem({ icon, shortcut, href, external, className, children, ...rest }: UserMenuItemProps) {\n  const inner = (\n    <>\n      {icon && <span className=\"grid size-4 shrink-0 place-items-center text-fg-3 transition-colors group-data-highlighted/item:text-fg-2 [&_svg]:size-4\">{icon}</span>}\n      <span className=\"min-w-0 flex-1 truncate\">{children}</span>\n      {external && <ArrowUpRight size={13} className=\"shrink-0 text-fg-4 transition-transform duration-150 group-data-highlighted/item:-translate-y-px group-data-highlighted/item:translate-x-px\" />}\n      {shortcut && <kbd className=\"shrink-0 font-mono text-2xs tracking-[0.04em] text-fg-4 pointer-coarse:hidden\">{shortcut}</kbd>}\n    </>\n  );\n  if (href)\n    return (\n      <Menu.LinkItem\n        href={href}\n        closeOnClick\n        data-row=\"\"\n        target={external ? \"_blank\" : undefined}\n        rel={external ? \"noopener noreferrer\" : undefined}\n        label={typeof children === \"string\" ? children : undefined}\n        className={cn(itemClass, \"cursor-pointer\", className)}\n      >\n        {inner}\n      </Menu.LinkItem>\n    );\n  return (\n    <Menu.Item data-row=\"\" className={cn(itemClass, className)} {...rest}>\n      {inner}\n    </Menu.Item>\n  );\n}\n\nexport function UserMenuSeparator({ className }: { className?: string }) {\n  return <Menu.Separator className={cn(\"-mx-1 my-1 h-px bg-line\", className)} />;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Status submenu\n * -----------------------------------------------------------------------------------------------*/\n\nfunction StatusSubmenu({ status, onStatusChange }: { status: UserStatus; onStatusChange: (s: UserStatus) => void }) {\n  return (\n    <Menu.SubmenuRoot>\n      <Menu.SubmenuTrigger data-row=\"\" className={cn(itemClass, \"data-popup-open:text-fg\")}>\n        <span className=\"grid size-4 place-items-center [--avatar-ring:var(--raised)]\">\n          <StatusDot status={status} size={9} />\n        </span>\n        <span className=\"min-w-0 flex-1 truncate\">{statusLabel[status]}</span>\n        <ChevronRight size={14} className=\"shrink-0 text-fg-4\" />\n      </Menu.SubmenuTrigger>\n      <Menu.Portal>\n        <Menu.Positioner\n          sideOffset={4}\n          alignOffset={-5}\n          collisionPadding={8}\n          collisionAvoidance={{ side: \"flip\", align: \"shift\", fallbackAxisSide: \"none\" }}\n          className=\"z-(--z-popover) outline-none\"\n        >\n          <Popup className=\"min-w-44\">\n            <Menu.RadioGroup value={status} onValueChange={(v: UserStatus) => onStatusChange(v)}>\n              {(Object.keys(statusLabel) as UserStatus[]).map((s) => (\n                <Menu.RadioItem key={s} value={s} closeOnClick data-row=\"\" className={itemClass}>\n                  <span className=\"grid size-4 place-items-center [--avatar-ring:var(--raised)]\">\n                    <StatusDot status={s} size={9} />\n                  </span>\n                  <span className=\"flex-1\">{statusLabel[s]}</span>\n                  <Menu.RadioItemIndicator className=\"text-fg\">\n                    <CheckGlyph />\n                  </Menu.RadioItemIndicator>\n                </Menu.RadioItem>\n              ))}\n            </Menu.RadioGroup>\n          </Popup>\n        </Menu.Positioner>\n      </Menu.Portal>\n    </Menu.SubmenuRoot>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Theme: a segmented control that lives inside the menu\n * -----------------------------------------------------------------------------------------------*/\n\nconst themes: { value: Theme; label: string; Icon: typeof Sun }[] = [\n  { value: \"light\", label: \"Light\", Icon: Sun },\n  { value: \"dark\", label: \"Dark\", Icon: Moon },\n  { value: \"system\", label: \"System\", Icon: Monitor },\n];\n\n// Three radio items laid out as segments. They stay menu items, so arrows walk through them\n// like any other row and a screen reader hears \"Dark theme, radio, checked\". The menu stays\n// open on a pick so the thumb can be seen sliding and the page seen changing behind it.\nfunction ThemeControl({ theme, onThemeChange }: { theme: Theme; onThemeChange: (t: Theme) => void }) {\n  const reduce = !!useReducedMotion();\n  const id = useId();\n  return (\n    <Menu.RadioGroup value={theme} onValueChange={(v: Theme) => onThemeChange(v)} className=\"flex h-9 items-center justify-between gap-3 pl-2 pr-1\">\n      <Menu.GroupLabel className=\"text-[13px] text-fg-2\">Theme</Menu.GroupLabel>\n      <LayoutGroup id={id}>\n        <div className=\"relative flex rounded-full border border-line-2 bg-frame p-0.5\">\n          {themes.map(({ value, label, Icon }) => (\n            <Menu.RadioItem\n              key={value}\n              value={value}\n              label={`${label} theme`}\n              aria-label={`${label} theme`}\n              className={cn(\n                \"group/seg relative grid h-6 w-7 cursor-default place-items-center rounded-full text-fg-3 outline-none\",\n                \"transition-colors duration-150 data-checked:text-fg data-highlighted:text-fg\",\n                \"data-highlighted:[&>[data-ring]]:opacity-100\",\n              )}\n            >\n              {theme === value && (\n                <motion.span\n                  layoutId=\"thumb\"\n                  aria-hidden\n                  transition={reduce ? { duration: 0 } : spring.snappy}\n                  className=\"absolute inset-0 rounded-full bg-raised shadow-[var(--shadow),inset_0_0_0_1px_var(--line-2)]\"\n                />\n              )}\n              <span data-ring aria-hidden className=\"absolute inset-0 rounded-full bg-fg/[0.06] opacity-0 shadow-[inset_0_0_0_1px_var(--fg-3)] transition-opacity duration-100\" />\n              <Icon size={14} className=\"relative transition-transform duration-150 ease-out group-active/seg:scale-90\" />\n            </Menu.RadioItem>\n          ))}\n        </div>\n      </LayoutGroup>\n    </Menu.RadioGroup>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * Popup surface with one gliding highlight\n * -----------------------------------------------------------------------------------------------*/\n\nfunction Popup({ className, children }: { className?: string; children: React.ReactNode }) {\n  const [node, setNode] = useState<HTMLDivElement | null>(null);\n  const hl = useRef<HTMLDivElement>(null);\n  const { y, height, opacity } = useGlide(node, hl);\n  return (\n    <Menu.Popup\n      ref={setNode}\n      className={cn(\n        \"relative isolate w-64 max-w-(--available-width) max-h-(--available-height) overflow-y-auto overscroll-contain rounded-xl border border-line-2 bg-raised p-1 text-fg shadow-pop outline-none [scrollbar-width:none]\",\n        \"origin-(--transform-origin) transition-[opacity,scale,translate] duration-180 ease-out-expo\",\n        \"data-starting-style:scale-96 data-starting-style:opacity-0\",\n        \"data-[side=bottom]:data-starting-style:-translate-y-1 data-[side=top]:data-starting-style:translate-y-1\",\n        \"data-[side=inline-end]:data-starting-style:-translate-x-1 data-[side=right]:data-starting-style:-translate-x-1\",\n        \"data-ending-style:scale-98 data-ending-style:opacity-0 data-ending-style:duration-120\",\n        \"data-instant:duration-0 motion-reduce:scale-100 motion-reduce:translate-none\",\n        className,\n      )}\n    >\n      <motion.div ref={hl} aria-hidden style={{ y, height, opacity }} className=\"pointer-events-none absolute inset-x-1 top-0 -z-10 rounded-lg bg-fg/[0.06] data-pressed:bg-fg/[0.1]\" />\n      {children}\n    </Menu.Popup>\n  );\n}\n\n// Follows whichever row Base UI marks highlighted: springs after the pointer, jumps for\n// the keyboard, fades in place when it first appears. The theme segments carry their own\n// focus ring, so the row highlight ignores them.\nfunction useGlide(popup: HTMLDivElement | null, hlRef: React.RefObject<HTMLDivElement | null>) {\n  const reduce = useReducedMotion();\n  const y = useMotionValue(0);\n  const height = useMotionValue(0);\n  const opacity = useMotionValue(0);\n\n  useEffect(() => {\n    const hl = hlRef.current;\n    if (!popup || !hl) return;\n    let keyboard = false;\n    let shown = false;\n    const sync = () => {\n      const row =\n        popup.querySelector<HTMLElement>(\"[data-row][data-highlighted]\") ?? popup.querySelector<HTMLElement>(\"[data-row][data-popup-open]\");\n      if (!row) {\n        if (shown) animate(opacity, 0, { duration: 0.12 });\n        shown = false;\n        delete hl.dataset.pressed;\n        return;\n      }\n      let top = 0;\n      for (let n: HTMLElement | null = row; n && n !== popup; n = n.offsetParent as HTMLElement | null) top += n.offsetTop;\n      if (!shown || keyboard || reduce) {\n        y.jump(top);\n        height.jump(row.offsetHeight);\n      } else {\n        animate(y, top, spring.follow);\n        animate(height, row.offsetHeight, spring.follow);\n      }\n      animate(opacity, row.hasAttribute(\"data-highlighted\") ? 1 : 0.6, { duration: shown ? 0.08 : 0.1 });\n      shown = true;\n    };\n    const onKey = () => (keyboard = true);\n    const onMove = () => (keyboard = false);\n    const onDown = (e: PointerEvent) => {\n      if ((e.target as HTMLElement).closest(\"[data-row]:not([data-disabled])\")) hl.dataset.pressed = \"\";\n    };\n    const onUp = () => delete hl.dataset.pressed;\n    const observer = new MutationObserver(sync);\n    observer.observe(popup, { subtree: true, attributes: true, attributeFilter: [\"data-highlighted\", \"data-popup-open\"] });\n    popup.addEventListener(\"keydown\", onKey, true);\n    popup.addEventListener(\"pointermove\", onMove, true);\n    popup.addEventListener(\"pointerdown\", onDown);\n    window.addEventListener(\"pointerup\", onUp);\n    window.addEventListener(\"pointercancel\", onUp);\n    sync();\n    return () => {\n      observer.disconnect();\n      popup.removeEventListener(\"keydown\", onKey, true);\n      popup.removeEventListener(\"pointermove\", onMove, true);\n      popup.removeEventListener(\"pointerdown\", onDown);\n      window.removeEventListener(\"pointerup\", onUp);\n      window.removeEventListener(\"pointercancel\", onUp);\n    };\n  }, [popup, hlRef, reduce, y, height, opacity]);\n\n  return { y, height, opacity };\n}\n\nfunction CheckGlyph() {\n  return (\n    <svg width=\"14\" height=\"14\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.6} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M3.5 8.5 6.5 11.5 12.5 4.5\" />\n    </svg>\n  );\n}\n\nfunction SignOutIcon() {\n  return (\n    <svg width=\"16\" height=\"16\" viewBox=\"0 0 16 16\" fill=\"none\" stroke=\"currentColor\" strokeWidth={1.4} strokeLinecap=\"round\" strokeLinejoin=\"round\" aria-hidden>\n      <path d=\"M9.5 3.25H4.25a1 1 0 0 0-1 1v7.5a1 1 0 0 0 1 1H9.5M7 8h6.5M11 5.5 13.5 8 11 10.5\" />\n    </svg>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/user-menu"
}
