{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "user-menu",
  "type": "registry:ui",
  "title": "User menu",
  "description": "Your account, settings, theme, and sign out behind the avatar. Opens as a bottom sheet on phones.",
  "categories": [
    "actions"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/motion-tokens.json",
    "https://irisui.dev/r/utils.json"
  ],
  "files": [
    {
      "path": "src/registry/components/user-menu/user-menu.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport type { CSSProperties, FocusEvent, KeyboardEvent, MouseEvent, PointerEvent, ReactNode, Ref } from \"react\";\nimport { createPortal } from \"react-dom\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport type { PanInfo, Transition, Variants } from \"motion/react\";\nimport { ChevronDown, LoaderCircle, LogOut, Monitor, Moon, Sun, SunMoon } from \"lucide-react\";\nimport { motionTokens } from \"../lib/motion-tokens\";\nimport { cn } from \"../lib/utils\";\n\nexport type UserStatus = \"available\" | \"busy\" | \"away\";\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\n/** Statuses in menu order. Each one has its own dot shape as well as color: solid, barred, or hollow. */\nexport const userStatuses: { value: UserStatus; label: string }[] = [\n  { value: \"available\", label: \"Available\" },\n  { value: \"busy\", label: \"Busy\" },\n  { value: \"away\", label: \"Away\" },\n];\n\nconst themes: { value: ThemePreference; label: string; icon: ReactNode }[] = [\n  { value: \"light\", label: \"Light\", icon: <Sun size={14} strokeWidth={1.75} aria-hidden=\"true\" /> },\n  { value: \"dark\", label: \"Dark\", icon: <Moon size={14} strokeWidth={1.75} aria-hidden=\"true\" /> },\n  { value: \"system\", label: \"System\", icon: <Monitor size={14} strokeWidth={1.75} aria-hidden=\"true\" /> },\n];\n\nexport interface UserMenuUser {\n  name: string;\n  email: string;\n  plan?: string;\n  avatarSrc?: string;\n  avatarSrcSet?: string;\n}\nexport interface UserMenuItem {\n  label: string;\n  icon?: ReactNode;\n  keys?: string[];\n  onSelect?: () => void;\n}\n\n/**\n * The account menu behind a person's photo in an app's top bar. It holds a compact identity header, a short group of account\n * destinations, an inline theme switch, and sign out. On screens narrower than 640px it opens as a bottom sheet instead.\n * Theme and status choices keep the menu open; everything else closes it and returns focus to the trigger.\n * When `onSignOut` returns a promise, the item shows its progress and the menu closes once it settles.\n */\nexport interface UserMenuProps {\n  user: UserMenuUser;\n  /** Presence status. Passing `status` or `onStatusChange` shows the presence dot and an inline status switch. */\n  status?: UserStatus;\n  defaultStatus?: UserStatus;\n  onStatusChange?: (status: UserStatus) => void;\n  theme?: ThemePreference;\n  defaultTheme?: ThemePreference;\n  /** Reports the choice. Applying it to the page is up to the app. */\n  onThemeChange?: (theme: ThemePreference) => void;\n  /** Shows the inline light, dark, and system switch. */\n  showTheme?: boolean;\n  /** Account destinations with optional shortcut hints, such as [\"⌘\", \",\"]. Keep it to three or four. */\n  items?: UserMenuItem[];\n  onSignOut?: () => void | Promise<unknown>;\n  signOutKeys?: string[];\n  align?: \"start\" | \"center\" | \"end\";\n  /** Shows the name and a chevron beside the avatar from 640px up. */\n  showName?: boolean;\n  open?: boolean;\n  defaultOpen?: boolean;\n  onOpenChange?: (open: boolean) => void;\n  /** Renders the desktop panel in a portal on the body. Pass false to keep it inside the trigger's wrapper. */\n  portal?: boolean;\n  /** The trigger button, for returning focus after the menu is swapped out. */\n  ref?: Ref<HTMLButtonElement>;\n  className?: string;\n}\n\ntype Highlight = { top: number; height: number; tone?: string; glide: boolean };\ntype OpenReason = \"first\" | \"last\" | \"pointer\" | null;\n\nconst compactQuery = \"(max-width: 639px)\";\nconst subscribeCompact = (change: () => void) => {\n  const query = window.matchMedia(compactQuery);\n  query.addEventListener(\"change\", change);\n  return () => query.removeEventListener(\"change\", change);\n};\nconst subscribeNothing = () => () => {};\nconst statusLabel = (status: UserStatus) => userStatuses.find((option) => option.value === status)?.label ?? status;\nconst enter = [...motionTokens.ease.enter] as [number, number, number, number];\nconst standard = [...motionTokens.ease.standard] as [number, number, number, number];\nconst exitEase = [0.4, 0, 1, 1] as [number, number, number, number];\n\n/** The panel grows out of the trigger on a critically damped spring; rows follow a beat later. Closing is a short fade. */\nconst panelMotion: Variants = {\n  closed: { opacity: 0, scale: 0.94 },\n  open: {\n    opacity: 1,\n    scale: 1,\n    transition: {\n      type: \"spring\",\n      visualDuration: 0.3,\n      bounce: 0,\n      opacity: { duration: motionTokens.duration.fast, ease: enter },\n      delayChildren: 0.03,\n      staggerChildren: 0.016,\n    },\n  },\n  exit: { opacity: 0, scale: 0.97, transition: { duration: motionTokens.duration.instant, ease: exitEase } },\n};\nconst sheetMotion: Variants = {\n  closed: { y: \"100%\" },\n  open: {\n    y: 0,\n    transition: { ...motionTokens.spring.smooth, visualDuration: 0.36, delayChildren: 0.06, staggerChildren: 0.02 },\n  },\n  exit: { y: \"100%\", transition: { duration: 0.22, ease: exitEase } },\n};\nconst stillMotion: Variants = {\n  closed: { opacity: 0 },\n  open: { opacity: 1, transition: { duration: motionTokens.duration.instant } },\n  exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } },\n};\nconst rowMotion: Variants = {\n  closed: { opacity: 0, y: 3 },\n  open: { opacity: 1, y: 0, transition: { duration: 0.2, ease: enter } },\n};\n\n/* The trigger anchors the menu, so it answers hover and press with colour, never with scale. Its surface colour feeds the\n   presence dot's cut-outs, so the dot always matches what it sits on. */\nconst triggerClass =\n  \"group/trig relative m-0 inline-flex h-10 flex-none cursor-pointer items-center gap-2 rounded-button border-0 bg-transparent p-1 font-body text-sm leading-[inherit] tracking-body text-ink [-webkit-tap-highlight-color:transparent] [--presence-surface:var(--user-menu-surface,var(--surface))] transition-[background-color] duration-fast ease-standard hover:bg-hover hover:[--presence-surface:var(--hover)] active:bg-[color-mix(in_oklab,var(--hover),var(--ink)_8%)] active:[--presence-surface:color-mix(in_oklab,var(--hover),var(--ink)_8%)] data-name:pr-2.5 data-[state=open]:not-active:bg-hover data-[state=open]:not-active:[--presence-surface:var(--hover)] max-sm:data-name:pr-1 motion-reduce:transition-none\";\n/* One mark, three shapes: solid for available, a bar for busy, a ring for away. Colour and shape change together, so status\n   never rests on colour alone. The hole and the bar are cut in the surface colour the dot sits on (--presence-surface). */\nconst dotClass =\n  \"relative inline-block size-(--dot-size,10px) flex-none rounded-full bg-(--dot-color) [--dot-color:var(--green)] [transition:background-color_var(--duration-standard)_var(--ease-standard),box-shadow_var(--duration-fast)_var(--ease-standard)] before:absolute before:inset-0 before:m-auto before:size-[46%] before:rounded-full before:bg-(--presence-surface,var(--surface)) before:[transform:scale(0)] before:[transition:transform_var(--duration-spring)_var(--ease-spring),background-color_var(--duration-fast)_var(--ease-standard)] before:content-[''] after:absolute after:inset-0 after:m-auto after:h-[22%] after:min-h-[1.5px] after:w-[58%] after:rounded-[1px] after:bg-(--presence-surface,var(--surface)) after:[transform:scaleX(0)] after:[transition:transform_var(--duration-spring)_var(--ease-spring),background-color_var(--duration-fast)_var(--ease-standard)] after:content-[''] data-[status=away]:[--dot-color:var(--orange)] data-[status=away]:before:[transform:scale(1)] data-[status=busy]:[--dot-color:var(--red)] data-[status=busy]:after:[transform:scaleX(1)] data-[status=offline]:[--dot-color:var(--ink-3)] data-[status=offline]:before:[transform:scale(1)] motion-reduce:transition-none motion-reduce:before:transition-none motion-reduce:after:transition-none\";\nconst portraitClass =\n  \"block size-full rounded-full bg-hover object-cover object-[50%_15%] outline -outline-offset-1 outline-[color-mix(in_oklab,var(--ink)_9%,transparent)] select-none\";\n/* Inside the bottom sheet (aria-modal) rows grow and text steps up to the base size. */\nconst iconClass =\n  \"inline-grid w-4 flex-none place-items-center text-ink-2 transition-[color] duration-fast ease-standard group-focus/item:text-ink motion-reduce:transition-none\";\nconst itemClass =\n  \"group/item relative m-0 box-border flex h-(--menu-row) w-full cursor-pointer items-center gap-2.5 rounded-[6px] border-0 bg-transparent px-2 text-left text-[13px] text-ink outline-none select-none [-webkit-tap-highlight-color:transparent] transition-[color] duration-fast ease-standard group-aria-[modal=true]/panel:text-base group-aria-[modal=true]/panel:leading-[inherit] motion-reduce:transition-none [@media(hover:none)]:active:bg-hover\";\nconst separatorClass =\n  \"-mx-(--menu-inset) border-0 my-(--menu-inset) h-[.5px] bg-(--menu-line) [@media(max-resolution:1dppx)]:h-px [@media(max-resolution:1dppx)]:bg-[color-mix(in_oklab,var(--ink)_6%,transparent)]\";\n/* The panel scales out of the avatar it belongs to; the component sets the origin once it is placed. */\nconst panelClass =\n  \"group/panel fixed top-0 left-0 z-60 box-border w-62 max-w-[calc(100vw-24px)] origin-[var(--origin-x,100%)_var(--origin-y,0px)] rounded-(--menu-radius) border-[.5px] border-line bg-surface p-(--menu-inset) text-left font-body leading-[1.3] tracking-[-.01em] text-ink shadow-floating outline-none [--menu-inset:4px] [--menu-line:color-mix(in_oklab,var(--ink)_9%,transparent)] [--menu-radius:10px] [--menu-row:32px] [--presence-surface:var(--surface)] data-inline:absolute data-inline:top-[calc(100%+8px)] data-inline:left-auto data-inline:data-[align=center]:left-1/2 data-inline:data-[align=center]:-ml-31 data-inline:data-[align=end]:right-0 data-inline:data-[align=start]:left-0\";\n/* Below 640px the same content rises as a bottom sheet with larger rows. */\nconst sheetClass = cn(\n  panelClass,\n  \"top-auto right-0 bottom-0 left-0 z-71 max-h-[88dvh] w-auto max-w-none overflow-y-auto overscroll-contain rounded-t-panel rounded-b-none border-b-0 px-(--menu-inset) pt-2 pb-[calc(12px+env(safe-area-inset-bottom))] [--menu-inset:10px] [--menu-row:46px]\",\n);\n\n/** A presence mark that morphs between statuses: the color crossfades while a hole opens for away and a bar slides in for busy. */\nexport function PresenceDot({ status, className }: { status: UserStatus | \"offline\"; className?: string }) {\n  return <span className={cn(dotClass, className)} data-status={status} aria-hidden=\"true\" />;\n}\n\nfunction Portrait({ user }: { user: UserMenuUser }) {\n  const [failed, setFailed] = useState<string>();\n  if (!user.avatarSrc || failed === user.avatarSrc) {\n    const initials = user.name\n      .trim()\n      .split(/\\s+/)\n      .slice(0, 2)\n      .map((part) => part[0]?.toUpperCase())\n      .join(\"\");\n    return (\n      <span\n        className={cn(\n          portraitClass,\n          \"grid place-items-center text-[length:calc(var(--face-size)*.36)] leading-none font-medium text-ink-2\",\n        )}\n      >\n        {initials}\n      </span>\n    );\n  }\n  return (\n    // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic; pass optimized URLs as avatarSrc and avatarSrcSet.\n    <img\n      className={portraitClass}\n      src={user.avatarSrc}\n      srcSet={user.avatarSrcSet}\n      alt=\"\"\n      decoding=\"async\"\n      draggable={false}\n      onError={() => setFailed(user.avatarSrc)}\n    />\n  );\n}\n\nfunction Face({ user, status, size }: { user: UserMenuUser; status?: UserStatus; size: \"sm\" | \"md\" }) {\n  return (\n    <span\n      className=\"relative inline-grid size-(--face-size) flex-none data-[size=md]:[--dot-size:10px] data-[size=md]:[--face-size:34px] data-[size=sm]:[--dot-size:11px] data-[size=sm]:[--face-size:32px]\"\n      data-size={size}\n    >\n      <Portrait user={user} />\n      {status && (\n        <PresenceDot\n          status={status}\n          className=\"absolute -right-px -bottom-px shadow-[0_0_0_2px_var(--presence-surface,var(--surface))]\"\n        />\n      )}\n    </span>\n  );\n}\n\n/** Changing text rises out of a small blur while the old text lifts away. */\nfunction Rise({ text, reduced }: { text: string; reduced: boolean }) {\n  return (\n    <AnimatePresence mode=\"popLayout\" initial={false}>\n      <motion.span\n        key={text}\n        className=\"inline-block whitespace-nowrap\"\n        initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.subtle}px)` }}\n        animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n        exit={\n          reduced\n            ? { opacity: 0, transition: { duration: 0 } }\n            : {\n                opacity: 0,\n                y: \"-0.35em\",\n                filter: `blur(${motionTokens.blur.subtle}px)`,\n                transition: { duration: motionTokens.duration.fast, ease: standard },\n              }\n        }\n        transition={reduced ? { duration: 0 } : { duration: motionTokens.duration.standard, ease: enter }}\n      >\n        {text}\n      </motion.span>\n    </AnimatePresence>\n  );\n}\n\nfunction Keys({ keys }: { keys: string[] }) {\n  return (\n    <kbd\n      className=\"ml-auto inline-flex flex-none items-center gap-px font-body text-xs leading-[inherit] tracking-normal text-ink-3 tabular-nums opacity-80 group-aria-[modal=true]/panel:hidden\"\n      aria-hidden=\"true\"\n    >\n      {keys.map((key, index) => (\n        <kbd key={`${key}-${index}`} className=\"inline-block min-w-[1.05em] text-center font-[inherit]\">\n          {key}\n        </kbd>\n      ))}\n    </kbd>\n  );\n}\n\n/** A compact inline switch. It is one stop in the menu: up and down pass over it, left and right choose within it. */\nfunction Segmented<T extends string>({\n  label,\n  icon,\n  value,\n  options,\n  onChange,\n  variants,\n}: {\n  label: string;\n  icon: ReactNode;\n  value: T;\n  options: { value: T; label: string; icon: ReactNode }[];\n  onChange: (value: T) => void;\n  variants?: Variants;\n}) {\n  const index = Math.max(\n    0,\n    options.findIndex((option) => option.value === value),\n  );\n  return (\n    <motion.div\n      className=\"relative flex h-9 items-center gap-2.5 pr-1 pl-2 group-aria-[modal=true]/panel:h-13\"\n      data-stop=\"group\"\n      data-label={label}\n      variants={variants}\n    >\n      <span className={cn(iconClass, \"[--dot-size:10px]\")} aria-hidden=\"true\">\n        {icon}\n      </span>\n      <span\n        className=\"flex-1 text-[13px] text-ink group-aria-[modal=true]/panel:text-base group-aria-[modal=true]/panel:leading-[inherit]\"\n        aria-hidden=\"true\"\n      >\n        {label}\n      </span>\n      <div\n        className=\"relative isolate inline-grid h-[26px] flex-none auto-cols-(--segment) grid-flow-col rounded-button bg-[color-mix(in_oklab,var(--ink)_5%,transparent)] p-0.5 [--segment:28px] group-aria-[modal=true]/panel:h-[34px] group-aria-[modal=true]/panel:[--segment:40px]\"\n        role=\"group\"\n        aria-label={label}\n        style={{ \"--index\": index, \"--count\": options.length } as CSSProperties}\n      >\n        {/* The thumb slides under the icons; the group owns the stacking context. */}\n        <span\n          className=\"absolute top-0.5 bottom-0.5 left-0.5 -z-1 w-(--segment) rounded-button bg-surface shadow-[0_1px_1.5px_oklch(0%_0_0/.08),0_0_0_.5px_var(--menu-line)] [transform:translateX(calc(var(--index)*100%))] [transition:transform_var(--duration-spring)_var(--ease-spring)] motion-reduce:transition-none dark:bg-[color-mix(in_oklab,var(--ink)_20%,var(--surface))] dark:shadow-none\"\n          aria-hidden=\"true\"\n        />\n        {options.map((option) => (\n          <button\n            key={option.value}\n            type=\"button\"\n            role=\"menuitemradio\"\n            tabIndex={-1}\n            aria-checked={option.value === value}\n            aria-label={option.label}\n            title={option.label}\n            className=\"m-0 grid cursor-pointer place-items-center rounded-button border-0 bg-transparent p-0 text-ink-3 outline-none [-webkit-tap-highlight-color:transparent] [--dot-size:9px] [--presence-surface:var(--surface)] transition-[color] duration-fast ease-standard hover:text-ink-2 aria-checked:text-ink motion-reduce:transition-none\"\n            onClick={() => onChange(option.value)}\n          >\n            {option.icon}\n          </button>\n        ))}\n      </div>\n    </motion.div>\n  );\n}\n\nconst getStops = (root: HTMLElement | null) => Array.from(root?.querySelectorAll<HTMLElement>(\"[data-stop]\") ?? []);\nfunction focusStop(stop: HTMLElement | undefined) {\n  if (!stop) return;\n  const target =\n    stop.dataset.stop === \"group\"\n      ? (stop.querySelector<HTMLElement>('[aria-checked=\"true\"]') ?? stop.querySelector<HTMLElement>(\"button\"))\n      : stop;\n  target?.focus({ preventScroll: true });\n}\n\nexport function UserMenu({\n  user,\n  status: statusProp,\n  defaultStatus = \"available\",\n  onStatusChange,\n  theme: themeProp,\n  defaultTheme = \"system\",\n  onThemeChange,\n  showTheme = true,\n  items = [],\n  onSignOut,\n  signOutKeys,\n  align = \"end\",\n  showName = false,\n  open: openProp,\n  defaultOpen = false,\n  onOpenChange,\n  portal = true,\n  ref,\n  className,\n}: UserMenuProps) {\n  const id = useId();\n  const menuId = `${id}-menu`;\n  const triggerId = `${id}-trigger`;\n  const reduced = !!useReducedMotion();\n  const hydrated = useSyncExternalStore(\n    subscribeNothing,\n    () => true,\n    () => false,\n  );\n  const compact = useSyncExternalStore(\n    subscribeCompact,\n    () => window.matchMedia(compactQuery).matches,\n    () => false,\n  );\n  const [innerOpen, setInnerOpen] = useState(defaultOpen);\n  const [innerStatus, setInnerStatus] = useState(defaultStatus);\n  const [innerTheme, setInnerTheme] = useState(defaultTheme);\n  const [signingOut, setSigningOut] = useState(false);\n  const [highlight, setHighlight] = useState<Highlight | null>(null);\n  const open = openProp ?? innerOpen;\n  const showStatus = statusProp !== undefined || onStatusChange !== undefined;\n  const status = statusProp ?? innerStatus;\n  const theme = themeProp ?? innerTheme;\n  const rootRef = useRef<HTMLSpanElement>(null);\n  const triggerRef = useRef<HTMLButtonElement | null>(null);\n  const surfaceRef = useRef<HTMLDivElement>(null);\n  const listRef = useRef<HTMLDivElement>(null);\n  const reason = useRef<OpenReason>(null);\n  const typed = useRef({ text: \"\", timer: 0 });\n  const mounted = useRef(true);\n  const pending = useRef(false);\n  const sheet = hydrated && compact;\n  const inline = !portal && !sheet;\n\n  useEffect(() => {\n    mounted.current = true;\n    const current = typed.current;\n    return () => {\n      mounted.current = false;\n      window.clearTimeout(current.timer);\n    };\n  }, []);\n\n  function setTriggerRef(node: HTMLButtonElement | null) {\n    triggerRef.current = node;\n    if (typeof ref === \"function\") ref(node);\n    else if (ref) (ref as { current: HTMLButtonElement | null }).current = node;\n  }\n\n  function setOpen(next: boolean, why: OpenReason = null) {\n    reason.current = next ? why : null;\n    if (next) setSigningOut(pending.current);\n    setHighlight(null);\n    setInnerOpen(next);\n    onOpenChange?.(next);\n  }\n  function close(returnFocus: boolean) {\n    setOpen(false);\n    if (returnFocus) triggerRef.current?.focus({ preventScroll: true });\n  }\n\n  // Focus moves in only when a person opened the menu, so a menu that starts open never steals focus or scrolls the page.\n  useEffect(() => {\n    if (!open) return;\n    const why = reason.current;\n    reason.current = null;\n    if (!why) return;\n    const frame = requestAnimationFrame(() => {\n      const stops = getStops(listRef.current);\n      if (why === \"first\") focusStop(stops[0]);\n      else if (why === \"last\") focusStop(stops.at(-1));\n      else surfaceRef.current?.focus({ preventScroll: true });\n    });\n    return () => cancelAnimationFrame(frame);\n  }, [open, sheet]);\n\n  // The panel is placed against the trigger and scales from the avatar's center, flipping above when there is no room below.\n  useLayoutEffect(() => {\n    if (!open || sheet) return;\n    const place = () => {\n      const panel = surfaceRef.current,\n        trigger = triggerRef.current;\n      if (!panel || !trigger) return;\n      const rect = trigger.getBoundingClientRect();\n      const width = panel.offsetWidth,\n        height = panel.offsetHeight;\n      let left: number, top: number;\n      if (inline) {\n        const root = rootRef.current?.getBoundingClientRect();\n        left = (root?.left ?? 0) + panel.offsetLeft;\n        top = (root?.top ?? 0) + panel.offsetTop;\n      } else {\n        const wanted =\n          align === \"start\"\n            ? rect.left\n            : align === \"center\"\n              ? rect.left + rect.width / 2 - width / 2\n              : rect.right - width;\n        left = Math.min(Math.max(12, wanted), window.innerWidth - width - 12);\n        const below = rect.bottom + 8;\n        top = below + height > window.innerHeight - 12 && rect.top - 8 - height > 12 ? rect.top - 8 - height : below;\n        panel.style.left = `${left}px`;\n        panel.style.top = `${top}px`;\n      }\n      const faceCenter = rect.left + Math.min(rect.width, 40) / 2;\n      const originX = align === \"end\" && rect.width > 40 ? rect.right - 20 - left : faceCenter - left;\n      panel.style.setProperty(\"--origin-x\", `${Math.min(Math.max(0, originX), width)}px`);\n      panel.style.setProperty(\"--origin-y\", top < rect.top ? `${height}px` : \"0px\");\n    };\n    place();\n    if (inline) return;\n    window.addEventListener(\"resize\", place);\n    window.addEventListener(\"scroll\", place, true);\n    return () => {\n      window.removeEventListener(\"resize\", place);\n      window.removeEventListener(\"scroll\", place, true);\n    };\n  }, [open, sheet, inline, align]);\n\n  // Outside presses close the panel. The sheet has its own scrim.\n  useEffect(() => {\n    if (!open || sheet) return;\n    const onPointerDown = (event: globalThis.PointerEvent) => {\n      const target = event.target as Node;\n      if (surfaceRef.current?.contains(target) || triggerRef.current?.contains(target)) return;\n      reason.current = null;\n      setHighlight(null);\n      setInnerOpen(false);\n      onOpenChange?.(false);\n    };\n    document.addEventListener(\"pointerdown\", onPointerDown, true);\n    return () => document.removeEventListener(\"pointerdown\", onPointerDown, true);\n  }, [open, sheet, onOpenChange]);\n\n  // The page behind the sheet stays put.\n  useEffect(() => {\n    if (!open || !sheet) return;\n    const root = document.documentElement;\n    const previous = root.style.overflow;\n    root.style.overflow = \"hidden\";\n    return () => {\n      root.style.overflow = previous;\n    };\n  }, [open, sheet]);\n\n  function changeStatus(value: UserStatus) {\n    setInnerStatus(value);\n    onStatusChange?.(value);\n  }\n  function changeTheme(value: ThemePreference) {\n    setInnerTheme(value);\n    onThemeChange?.(value);\n  }\n\n  function signOut() {\n    if (pending.current) return;\n    const result = onSignOut?.();\n    if (!result || typeof result.then !== \"function\") {\n      close(true);\n      return;\n    }\n    // Async sign out keeps the menu open with its progress on the item, then closes once it settles.\n    pending.current = true;\n    setSigningOut(true);\n    const done = () => {\n      pending.current = false;\n      if (mounted.current) close(false);\n    };\n    result.then(done, done);\n  }\n\n  function onListFocus(event: FocusEvent<HTMLDivElement>) {\n    const stop = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>(\"[data-stop]\") : null;\n    if (!stop) {\n      setHighlight(null);\n      return;\n    }\n    setHighlight((current) => ({\n      top: stop.offsetTop,\n      height: stop.offsetHeight,\n      tone: stop.dataset.tone,\n      glide: current !== null,\n    }));\n  }\n\n  // Focus follows a mouse or pen, so the keyboard carries on from wherever the pointer left the highlight.\n  function onItemPointerMove(event: PointerEvent<HTMLElement>) {\n    if (event.pointerType === \"touch\") return;\n    if (document.activeElement !== event.currentTarget) event.currentTarget.focus({ preventScroll: true });\n  }\n  // The inline switches are not rows, so the pointer over them releases the highlight instead of dragging it along.\n  function onListPointerMove(event: PointerEvent<HTMLElement>) {\n    if (event.pointerType === \"touch\" || !highlight) return;\n    const group = event.target instanceof HTMLElement ? event.target.closest<HTMLElement>('[data-stop=\"group\"]') : null;\n    if (!group || group.contains(document.activeElement)) return;\n    setHighlight(null);\n    surfaceRef.current?.focus({ preventScroll: true });\n  }\n  function onListPointerLeave(event: PointerEvent<HTMLElement>) {\n    if (event.pointerType === \"touch\") return;\n    setHighlight(null);\n    surfaceRef.current?.focus({ preventScroll: true });\n  }\n\n  function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {\n    const stops = getStops(listRef.current);\n    const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n    const current = active?.closest<HTMLElement>(\"[data-stop]\") ?? null;\n    const index = current ? stops.indexOf(current) : -1;\n    const step = (to: HTMLElement | undefined) => {\n      event.preventDefault();\n      focusStop(to);\n    };\n    switch (event.key) {\n      case \"ArrowDown\":\n        return step(stops[(index + 1) % stops.length]);\n      case \"ArrowUp\":\n        return step(stops[index <= 0 ? stops.length - 1 : index - 1]);\n      case \"Home\":\n        return step(stops[0]);\n      case \"End\":\n        return step(stops.at(-1));\n      case \"Escape\":\n      case \"Tab\":\n        event.preventDefault();\n        return close(true);\n      case \"ArrowLeft\":\n      case \"ArrowRight\": {\n        if (current?.dataset.stop !== \"group\") return;\n        event.preventDefault();\n        const segments = Array.from(current.querySelectorAll<HTMLButtonElement>('[role=\"menuitemradio\"]'));\n        const at = Math.max(\n          0,\n          segments.findIndex((segment) => segment.getAttribute(\"aria-checked\") === \"true\"),\n        );\n        const next = segments[(at + (event.key === \"ArrowRight\" ? 1 : -1) + segments.length) % segments.length];\n        next?.focus({ preventScroll: true });\n        next?.click();\n        return;\n      }\n    }\n    if (event.key.length !== 1 || event.ctrlKey || event.metaKey || event.altKey || event.key === \" \") return;\n    // Typeahead: letters jump to the next row whose label starts with what was typed.\n    const memory = typed.current;\n    window.clearTimeout(memory.timer);\n    memory.text += event.key.toLowerCase();\n    memory.timer = window.setTimeout(() => {\n      memory.text = \"\";\n    }, 500);\n    const ordered = [...stops.slice(index + 1), ...stops.slice(0, index + 1)];\n    const search =\n      memory.text.length > 1 && current?.dataset.label?.toLowerCase().startsWith(memory.text) ? [current] : ordered;\n    const match = search.find((stop) => stop.dataset.label?.toLowerCase().startsWith(memory.text));\n    if (match) step(match);\n  }\n\n  function onTriggerClick(event: MouseEvent<HTMLButtonElement>) {\n    if (open) {\n      close(false);\n      return;\n    }\n    setOpen(true, event.detail === 0 ? \"first\" : \"pointer\");\n  }\n  function onTriggerKeyDown(event: KeyboardEvent<HTMLButtonElement>) {\n    if (event.key !== \"ArrowDown\" && event.key !== \"ArrowUp\") return;\n    event.preventDefault();\n    setOpen(true, event.key === \"ArrowDown\" ? \"first\" : \"last\");\n  }\n\n  function onDragEnd(_: unknown, info: PanInfo) {\n    if (info.offset.y > 80 || info.velocity.y > 500) close(true);\n  }\n\n  const glide: Transition = highlight?.glide && !reduced ? motionTokens.spring.snappy : { duration: 0 };\n  const row = reduced ? undefined : rowMotion;\n  const menuProps = { id: menuId, role: \"menu\", \"aria-labelledby\": triggerId, tabIndex: -1, onKeyDown };\n\n  const content = (\n    <>\n      <motion.div\n        className=\"flex items-center gap-2.5 p-2 group-aria-[modal=true]/panel:px-2 group-aria-[modal=true]/panel:pt-1 group-aria-[modal=true]/panel:pb-3\"\n        variants={row}\n      >\n        <Face user={user} status={showStatus ? status : undefined} size=\"md\" />\n        <div className=\"grid min-w-0 flex-1 gap-0.5\">\n          {/* The name keeps its room; a long plan or role shrinks and truncates first. */}\n          <div className=\"flex min-w-0 items-center gap-1.5\">\n            <span className=\"min-w-0 flex-[0_1_auto] overflow-hidden text-[13px] leading-[1.25] font-medium tracking-[-.012em] text-ellipsis whitespace-nowrap text-ink\">\n              {user.name}\n            </span>\n            {user.plan && (\n              <span className=\"inline-block h-[17px] max-w-[55%] min-w-0 flex-[0_100_auto] overflow-hidden rounded-[4px] border-[.5px] border-(--menu-line) bg-[color-mix(in_oklab,var(--ink)_4%,transparent)] px-[5px] text-[11px] leading-4 font-medium tracking-[-.005em] text-ellipsis whitespace-nowrap text-ink-2\">\n                {user.plan}\n              </span>\n            )}\n          </div>\n          <span\n            className=\"overflow-hidden text-xs leading-[1.3] tracking-[-.008em] text-ellipsis whitespace-nowrap text-ink-3\"\n            title={user.email}\n          >\n            {user.email}\n          </span>\n        </div>\n      </motion.div>\n      <div\n        ref={listRef}\n        className=\"relative\"\n        onFocus={onListFocus}\n        onPointerMove={onListPointerMove}\n        onPointerLeave={onListPointerLeave}\n      >\n        {/* One highlight follows the pointer and the keyboard. It is the only hover treatment in the menu. */}\n        <motion.span\n          className=\"pointer-events-none absolute top-0 right-0 left-0 rounded-[6px] bg-[color-mix(in_oklab,var(--ink)_6%,transparent)] opacity-0\"\n          data-tone={highlight?.tone}\n          aria-hidden=\"true\"\n          initial={false}\n          animate={highlight ? { y: highlight.top, height: highlight.height, opacity: 1 } : { opacity: 0 }}\n          transition={{ default: glide, opacity: { duration: reduced ? 0 : motionTokens.duration.instant } }}\n        />\n        {items.length > 0 && (\n          <>\n            <hr className={separatorClass} />\n            {items.map((item) => (\n              <motion.button\n                key={item.label}\n                type=\"button\"\n                role=\"menuitem\"\n                tabIndex={-1}\n                className={itemClass}\n                data-stop=\"item\"\n                data-label={item.label}\n                variants={row}\n                onPointerMove={onItemPointerMove}\n                onClick={() => {\n                  close(true);\n                  item.onSelect?.();\n                }}\n              >\n                <span className={iconClass} aria-hidden=\"true\">\n                  {item.icon}\n                </span>\n                <span className=\"relative inline-flex min-w-0 flex-1\">{item.label}</span>\n                {item.keys && <Keys keys={item.keys} />}\n              </motion.button>\n            ))}\n          </>\n        )}\n        {(showTheme || showStatus) && (\n          <>\n            <hr className={separatorClass} />\n            {showStatus && (\n              <Segmented\n                label=\"Status\"\n                icon={<PresenceDot status={status} />}\n                value={status}\n                onChange={changeStatus}\n                options={userStatuses.map((option) => ({ ...option, icon: <PresenceDot status={option.value} /> }))}\n                variants={row}\n              />\n            )}\n            {showTheme && (\n              <Segmented\n                label=\"Theme\"\n                icon={<SunMoon size={16} strokeWidth={1.75} />}\n                value={theme}\n                onChange={changeTheme}\n                options={themes}\n                variants={row}\n              />\n            )}\n          </>\n        )}\n        <hr className={separatorClass} />\n        <motion.button\n          type=\"button\"\n          role=\"menuitem\"\n          tabIndex={-1}\n          className={itemClass}\n          data-stop=\"item\"\n          data-tone=\"danger\"\n          data-label=\"Sign out\"\n          variants={row}\n          aria-busy={signingOut || undefined}\n          onPointerMove={onItemPointerMove}\n          onClick={signOut}\n        >\n          <span className={iconClass} aria-hidden=\"true\">\n            {signingOut ? (\n              <LoaderCircle\n                className=\"animate-user-menu-spin motion-reduce:[animation-duration:2.4s]\"\n                size={16}\n                strokeWidth={1.75}\n              />\n            ) : (\n              <LogOut size={16} strokeWidth={1.75} />\n            )}\n          </span>\n          <span className=\"relative inline-flex min-w-0 flex-1\">\n            <Rise text={signingOut ? \"Signing out\" : \"Sign out\"} reduced={reduced} />\n          </span>\n          {signOutKeys && <Keys keys={signOutKeys} />}\n        </motion.button>\n      </div>\n    </>\n  );\n\n  const panel = (\n    <AnimatePresence>\n      {open && !sheet && (\n        <motion.div\n          key=\"panel\"\n          ref={surfaceRef}\n          {...menuProps}\n          className={panelClass}\n          data-inline={inline || undefined}\n          data-align={align}\n          variants={reduced ? stillMotion : panelMotion}\n          initial=\"closed\"\n          animate=\"open\"\n          exit=\"exit\"\n        >\n          {content}\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n\n  const bottomSheet = (\n    <AnimatePresence>\n      {open && sheet && (\n        <motion.div\n          key=\"scrim\"\n          className=\"fixed inset-0 z-70 bg-[oklch(0%_0_0/.32)] [-webkit-tap-highlight-color:transparent]\"\n          aria-hidden=\"true\"\n          initial={{ opacity: 0 }}\n          animate={{ opacity: 1 }}\n          exit={{ opacity: 0 }}\n          transition={{ duration: reduced ? 0.1 : 0.2, ease: standard }}\n          onClick={() => close(true)}\n        />\n      )}\n      {open && sheet && (\n        <motion.div\n          key=\"sheet\"\n          ref={surfaceRef}\n          {...menuProps}\n          className={sheetClass}\n          aria-modal=\"true\"\n          variants={reduced ? stillMotion : sheetMotion}\n          initial=\"closed\"\n          animate=\"open\"\n          exit=\"exit\"\n          drag={reduced ? false : \"y\"}\n          dragConstraints={{ top: 0, bottom: 0 }}\n          dragElastic={{ top: 0.04, bottom: 0.9 }}\n          dragMomentum={false}\n          onDragEnd={onDragEnd}\n        >\n          <span\n            className=\"mx-auto mt-0 mb-2.5 block h-[5px] w-9 cursor-grab rounded-full bg-line-strong\"\n            aria-hidden=\"true\"\n          />\n          {content}\n        </motion.div>\n      )}\n    </AnimatePresence>\n  );\n\n  return (\n    <span ref={rootRef} className=\"relative inline-flex flex-none align-middle\">\n      <button\n        ref={setTriggerRef}\n        id={triggerId}\n        type=\"button\"\n        className={cn(triggerClass, className)}\n        data-state={open ? \"open\" : \"closed\"}\n        data-name={showName || undefined}\n        aria-haspopup=\"menu\"\n        aria-expanded={open}\n        aria-controls={open ? menuId : undefined}\n        aria-label={`Account menu, ${user.name}${showStatus ? `, ${statusLabel(status)}` : \"\"}`}\n        onClick={onTriggerClick}\n        onKeyDown={onTriggerKeyDown}\n      >\n        <Face user={user} status={showStatus ? status : undefined} size=\"sm\" />\n        {showName && (\n          <>\n            <span className=\"max-w-44 overflow-hidden leading-none font-medium text-ellipsis whitespace-nowrap max-sm:hidden\">\n              {user.name}\n            </span>\n            <ChevronDown\n              className=\"-ml-0.5 flex-none text-ink-3 [transition:transform_var(--duration-spring)_var(--ease-spring),color_var(--duration-fast)_var(--ease-standard)] group-hover/trig:text-ink group-data-[state=open]/trig:text-ink group-data-[state=open]/trig:[transform:rotate(180deg)] max-sm:hidden motion-reduce:transition-none\"\n              size={16}\n              strokeWidth={1.75}\n              aria-hidden=\"true\"\n            />\n          </>\n        )}\n      </button>\n      {sheet\n        ? createPortal(bottomSheet, document.body)\n        : inline\n          ? panel\n          : hydrated\n            ? createPortal(panel, document.body)\n            : null}\n    </span>\n  );\n}\n\nexport default UserMenu;\n",
      "type": "registry:component",
      "target": "@components/iris/user-menu/user-menu.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/user-menu",
  "meta": {
    "kind": "component",
    "group": "Actions",
    "export": "UserMenu",
    "tags": [
      "motion",
      "product"
    ],
    "docs": "https://irisui.dev/components/user-menu"
  }
}
