{"$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://uiarc.dev/r/arc-foundation.json"],"files":[{"path":"registry/components/user-menu/user-menu.tsx","type":"registry:component","target":"@components/arc/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 styles from \"./user-menu.module.css\";\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: },\n { value: \"dark\", label: \"Dark\", icon: },\n { value: \"system\", label: \"System\", icon: },\n];\n\nexport interface UserMenuUser { name: string; email: string; plan?: string; avatarSrc?: string; avatarSrcSet?: string }\nexport interface UserMenuItem { label: string; icon?: ReactNode; keys?: string[]; onSelect?: () => void }\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;\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;\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) => { const query = window.matchMedia(compactQuery); query.addEventListener(\"change\", change); return () => query.removeEventListener(\"change\", change); };\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: .94 },\n open: { opacity: 1, scale: 1, transition: { type: \"spring\", visualDuration: .3, bounce: 0, opacity: { duration: motionTokens.duration.fast, ease: enter }, delayChildren: .03, staggerChildren: .016 } },\n exit: { opacity: 0, scale: .97, transition: { duration: motionTokens.duration.instant, ease: exitEase } },\n};\nconst sheetMotion: Variants = {\n closed: { y: \"100%\" },\n open: { y: 0, transition: { ...motionTokens.spring.smooth, visualDuration: .36, delayChildren: .06, staggerChildren: .02 } },\n exit: { y: \"100%\", transition: { duration: .22, ease: exitEase } },\n};\nconst stillMotion: Variants = { closed: { opacity: 0 }, open: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, exit: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };\nconst rowMotion: Variants = { closed: { opacity: 0, y: 3 }, open: { opacity: 1, y: 0, transition: { duration: .2, ease: enter } } };\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 ;\n}\n\nfunction Portrait({ user }: { user: UserMenuUser }) {\n const [failed, setFailed] = useState();\n if (!user.avatarSrc || failed === user.avatarSrc) {\n const initials = user.name.trim().split(/\\s+/).slice(0, 2).map(part => part[0]?.toUpperCase()).join(\"\");\n return {initials};\n }\n // eslint-disable-next-line @next/next/no-img-element -- registry components stay framework agnostic; pass optimized URLs as avatarSrc and avatarSrcSet.\n return \"\" setFailed(user.avatarSrc)} />;\n}\n\nfunction Face({ user, status, size }: { user: UserMenuUser; status?: UserStatus; size: \"sm\" | \"md\" }) {\n return {status && };\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 {text}\n ;\n}\n\nfunction Keys({ keys }: { keys: string[] }) {\n return {keys.map((key, index) => {key})};\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({ label, icon, value, options, onChange, variants }: { label: string; icon: ReactNode; value: T; options: { value: T; label: string; icon: ReactNode }[]; onChange: (value: T) => void; variants?: Variants }) {\n const index = Math.max(0, options.findIndex(option => option.value === value));\n return \n {icon}\n {label}\n
\n \n {options.map(option => )}\n
\n
;\n}\n\nconst getStops = (root: HTMLElement | null) => Array.from(root?.querySelectorAll(\"[data-stop]\") ?? []);\nfunction focusStop(stop: HTMLElement | undefined) {\n if (!stop) return;\n const target = stop.dataset.stop === \"group\" ? stop.querySelector('[aria-checked=\"true\"]') ?? stop.querySelector(\"button\") : stop;\n target?.focus({ preventScroll: true });\n}\n\nexport function UserMenu({ user, status: statusProp, defaultStatus = \"available\", onStatusChange, theme: themeProp, defaultTheme = \"system\", onThemeChange, showTheme = true, items = [], onSignOut, signOutKeys, align = \"end\", showName = false, open: openProp, defaultOpen = false, onOpenChange, portal = true, ref, className }: UserMenuProps) {\n const id = useId();\n const menuId = `${id}-menu`;\n const triggerId = `${id}-trigger`;\n const reduced = !!useReducedMotion();\n const hydrated = useSyncExternalStore(subscribeNothing, () => true, () => false);\n const compact = useSyncExternalStore(subscribeCompact, () => window.matchMedia(compactQuery).matches, () => false);\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(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(null);\n const triggerRef = useRef(null);\n const surfaceRef = useRef(null);\n const listRef = useRef(null);\n const reason = useRef(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(() => { mounted.current = true; const current = typed.current; return () => { mounted.current = false; window.clearTimeout(current.timer); }; }, []);\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, trigger = triggerRef.current;\n if (!panel || !trigger) return;\n const rect = trigger.getBoundingClientRect();\n const width = panel.offsetWidth, 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 = align === \"start\" ? rect.left : align === \"center\" ? rect.left + rect.width / 2 - width / 2 : 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 () => { window.removeEventListener(\"resize\", place); window.removeEventListener(\"scroll\", place, true); };\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 () => { root.style.overflow = previous; };\n }, [open, sheet]);\n\n function changeStatus(value: UserStatus) { setInnerStatus(value); onStatusChange?.(value); }\n function changeTheme(value: ThemePreference) { setInnerTheme(value); onThemeChange?.(value); }\n\n function signOut() {\n if (pending.current) return;\n const result = onSignOut?.();\n if (!result || typeof result.then !== \"function\") { close(true); return; }\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 = () => { pending.current = false; if (mounted.current) close(false); };\n result.then(done, done);\n }\n\n function onListFocus(event: FocusEvent) {\n const stop = event.target instanceof HTMLElement ? event.target.closest(\"[data-stop]\") : null;\n if (!stop) { setHighlight(null); return; }\n setHighlight(current => ({ top: stop.offsetTop, height: stop.offsetHeight, tone: stop.dataset.tone, glide: current !== null }));\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) {\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) {\n if (event.pointerType === \"touch\" || !highlight) return;\n const group = event.target instanceof HTMLElement ? event.target.closest('[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) {\n if (event.pointerType === \"touch\") return;\n setHighlight(null);\n surfaceRef.current?.focus({ preventScroll: true });\n }\n\n function onKeyDown(event: KeyboardEvent) {\n const stops = getStops(listRef.current);\n const active = document.activeElement instanceof HTMLElement ? document.activeElement : null;\n const current = active?.closest(\"[data-stop]\") ?? null;\n const index = current ? stops.indexOf(current) : -1;\n const step = (to: HTMLElement | undefined) => { event.preventDefault(); focusStop(to); };\n switch (event.key) {\n case \"ArrowDown\": return step(stops[(index + 1) % stops.length]);\n case \"ArrowUp\": return step(stops[index <= 0 ? stops.length - 1 : index - 1]);\n case \"Home\": return step(stops[0]);\n case \"End\": return step(stops.at(-1));\n case \"Escape\": case \"Tab\": event.preventDefault(); return close(true);\n case \"ArrowLeft\": case \"ArrowRight\": {\n if (current?.dataset.stop !== \"group\") return;\n event.preventDefault();\n const segments = Array.from(current.querySelectorAll('[role=\"menuitemradio\"]'));\n const at = Math.max(0, segments.findIndex(segment => segment.getAttribute(\"aria-checked\") === \"true\"));\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(() => { memory.text = \"\"; }, 500);\n const ordered = [...stops.slice(index + 1), ...stops.slice(0, index + 1)];\n const search = 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) {\n if (open) { close(false); return; }\n setOpen(true, event.detail === 0 ? \"first\" : \"pointer\");\n }\n function onTriggerKeyDown(event: KeyboardEvent) {\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 \n
\n
{user.name}{user.plan && {user.plan}}
\n {user.email}\n
\n
\n
\n \n {items.length > 0 && <>\n
\n {items.map(item => { close(true); item.onSelect?.(); }}>\n {item.icon}\n {item.label}\n {item.keys && }\n )}\n }\n {(showTheme || showStatus) && <>\n
\n {showStatus && } value={status} onChange={changeStatus}\n options={userStatuses.map(option => ({ ...option, icon: }))} variants={row} />}\n {showTheme && } value={theme} onChange={changeTheme} options={themes} variants={row} />}\n }\n
\n \n {signingOut ? : }\n \n {signOutKeys && }\n \n
\n ;\n\n const panel = \n {open && !sheet && \n {content}\n }\n ;\n\n const bottomSheet = \n {open && sheet && close(true)} />}\n {open && sheet && \n \n {content}\n }\n ;\n\n return \n \n {sheet ? createPortal(bottomSheet, document.body) : inline ? panel : hydrated ? createPortal(panel, document.body) : null}\n ;\n}\n\nexport default UserMenu;\n"},{"path":"registry/components/user-menu/user-menu.module.css","type":"registry:component","target":"@components/arc/user-menu/user-menu.module.css","content":".root { position: relative; display: inline-flex; flex: none; vertical-align: middle; }\n\n/* The trigger anchors the menu, so it answers hover and press with color, never with scale. */\n.trigger { --presence-surface: var(--user-menu-surface, var(--surface)); position: relative; display: inline-flex; height: 40px; flex: none; align-items: center; gap: var(--space-2); margin: 0; padding: 4px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--foreground); font: inherit; font-family: var(--font-body); font-size: var(--text-sm); letter-spacing: var(--tracking-body); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background-color var(--duration-fast) var(--ease-standard); }\n.trigger[data-name] { padding-right: 10px; }\n@media (hover: hover) and (pointer: fine) { .trigger:hover { --presence-surface: var(--surface-muted); background: var(--surface-muted); } }\n.trigger[data-state=\"open\"] { --presence-surface: var(--surface-muted); background: var(--surface-muted); }\n.trigger:active { --presence-surface: color-mix(in oklab, var(--surface-muted), var(--foreground) 8%); background: color-mix(in oklab, var(--surface-muted), var(--foreground) 8%); }\n.triggerName { max-width: 11rem; overflow: hidden; font-weight: 500; line-height: 1; text-overflow: ellipsis; white-space: nowrap; }\n.chevron { flex: none; margin-left: -2px; color: var(--text-muted); transition: transform var(--duration-spring) var(--ease-spring), color var(--duration-fast) var(--ease-standard); }\n.trigger:hover .chevron, .trigger[data-state=\"open\"] .chevron { color: var(--foreground); }\n.trigger[data-state=\"open\"] .chevron { transform: rotate(180deg); }\n@media (max-width: 639px) {\n .trigger[data-name] { padding-right: 4px; }\n .triggerName, .chevron { display: none; }\n}\n\n.face { position: relative; display: inline-grid; width: var(--face-size); height: var(--face-size); flex: none; }\n.face[data-size=\"sm\"] { --face-size: 32px; --dot-size: 11px; }\n.face[data-size=\"md\"] { --face-size: 40px; --dot-size: 12px; }\n.portrait { display: block; width: 100%; height: 100%; border-radius: var(--radius-pill); background: var(--surface-muted); object-fit: cover; object-position: 50% 15%; outline: 1px solid color-mix(in oklab, var(--foreground) 9%, transparent); outline-offset: -1px; user-select: none; }\n.initials { display: grid; place-items: center; color: var(--text-secondary); font-size: calc(var(--face-size) * .36); font-weight: 500; line-height: 1; }\n.face .dot { position: absolute; right: -1px; bottom: -1px; box-shadow: 0 0 0 2px var(--presence-surface, var(--surface)); }\n\n/* One mark, three shapes: solid for available, a bar for busy, a ring for away. Color and shape change together, so status never rests on color alone. */\n.dot { --dot-color: var(--success); position: relative; display: inline-block; width: var(--dot-size, 10px); height: var(--dot-size, 10px); flex: none; border-radius: var(--radius-pill); background: var(--dot-color); transition: background-color var(--duration-standard) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard); }\n.dot::before, .dot::after { content: \"\"; position: absolute; inset: 0; margin: auto; background: var(--presence-surface, var(--surface)); transition: transform var(--duration-spring) var(--ease-spring), background-color var(--duration-fast) var(--ease-standard); }\n.dot::before { width: 46%; height: 46%; border-radius: var(--radius-pill); transform: scale(0); }\n.dot::after { width: 58%; height: 22%; min-height: 1.5px; border-radius: 1px; transform: scaleX(0); }\n.dot[data-status=\"busy\"] { --dot-color: var(--danger); }\n.dot[data-status=\"busy\"]::after { transform: scaleX(1); }\n.dot[data-status=\"away\"] { --dot-color: var(--warning); }\n.dot[data-status=\"away\"]::before, .dot[data-status=\"offline\"]::before { transform: scale(1); }\n.dot[data-status=\"offline\"] { --dot-color: var(--text-muted); }\n\n/* The panel scales out of the avatar it belongs to; the component sets the origin once it is placed. */\n.panel { --presence-surface: var(--surface-raised); --row: 40px; --inset: 6px; position: fixed; top: 0; left: 0; z-index: 60; box-sizing: border-box; width: 17rem; max-width: calc(100vw - 24px); padding: var(--inset); border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface-raised); box-shadow: var(--shadow-floating); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); line-height: var(--leading-body); text-align: left; outline: none; transform-origin: var(--origin-x, 100%) var(--origin-y, 0px); }\n.panel[data-inline] { position: absolute; top: calc(100% + 8px); left: auto; }\n.panel[data-inline][data-align=\"end\"] { right: 0; }\n.panel[data-inline][data-align=\"start\"] { left: 0; }\n.panel[data-inline][data-align=\"center\"] { left: 50%; margin-left: -8.5rem; }\n\n.header { display: flex; align-items: center; gap: var(--space-3); padding: 8px 8px 10px; }\n.identity { display: grid; min-width: 0; flex: 1; gap: 1px; }\n.nameRow { display: flex; min-width: 0; align-items: center; gap: var(--space-2); }\n.name { overflow: hidden; color: var(--foreground); font-size: var(--text-sm); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }\n.plan { display: inline-flex; flex: none; align-items: center; height: 18px; padding: 0 7px; border-radius: var(--radius-pill); background: var(--accent-subtle); color: var(--accent-strong); font-size: var(--text-xs); font-weight: 500; line-height: 1; }\n.email { overflow: hidden; color: var(--text-muted); font-size: var(--text-xs); text-overflow: ellipsis; white-space: nowrap; }\n\n.list { position: relative; }\n.separator { height: 1px; margin: var(--inset) calc(var(--inset) * -1); background: var(--border-subtle); }\n\n/* One highlight follows the pointer and the keyboard. It is the only hover treatment in the menu. */\n.highlight { position: absolute; top: 0; right: 0; left: 0; border-radius: calc(var(--radius-panel) - var(--inset)); background: var(--accent-subtle); opacity: 0; pointer-events: none; }\n.highlight[data-tone=\"danger\"] { background: color-mix(in oklab, var(--danger) 11%, transparent); }\n\n.item { position: relative; display: flex; width: 100%; height: var(--row); box-sizing: border-box; align-items: center; gap: var(--space-3); margin: 0; padding: 0 12px; border: 0; border-radius: calc(var(--radius-panel) - var(--inset)); background: transparent; color: var(--foreground); font: inherit; font-size: var(--text-sm); text-align: left; cursor: pointer; outline: none; user-select: none; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n.icon { display: inline-grid; width: 16px; flex: none; place-items: center; color: var(--text-secondary); transition: color var(--duration-fast) var(--ease-standard); }\n.item:focus .icon { color: var(--accent-strong); }\n.itemLabel { position: relative; display: inline-flex; min-width: 0; flex: 1; }\n.rise { display: inline-block; white-space: nowrap; }\n.item[data-tone=\"danger\"], .item[data-tone=\"danger\"] .icon { color: color-mix(in oklab, var(--danger) 58%, var(--text-secondary)); }\n.item[data-tone=\"danger\"]:focus, .item[data-tone=\"danger\"]:focus .icon { color: var(--danger); }\n@media (hover: none) { .item:active { background: var(--surface-muted); } }\n\n.keys { display: inline-flex; flex: none; align-items: center; gap: 1px; margin-left: auto; color: var(--text-muted); font-family: var(--font-body); font-size: var(--text-xs); letter-spacing: 0; opacity: .8; font-variant-numeric: tabular-nums; }\n.key { display: inline-block; min-width: 1.05em; font: inherit; text-align: center; }\n\n.row { position: relative; display: flex; height: 44px; align-items: center; gap: var(--space-3); padding: 0 6px 0 12px; }\n.rowLabel { flex: 1; color: var(--foreground); font-size: var(--text-sm); }\n.row .icon .dot { --dot-size: 10px; }\n/* The thumb slides under the icons; the group owns the stacking context. */\n.segments { --segment: 32px; position: relative; isolation: isolate; display: inline-grid; height: 30px; flex: none; grid-auto-columns: var(--segment); grid-auto-flow: column; padding: 2px; border-radius: var(--radius-pill); background: color-mix(in oklab, var(--foreground) 6%, transparent); }\n.thumb { position: absolute; z-index: -1; top: 2px; bottom: 2px; left: 2px; width: var(--segment); border-radius: var(--radius-pill); background: var(--surface-raised); box-shadow: 0 1px 2px oklch(0% 0 0 / .1), 0 0 0 1px var(--border-subtle); transform: translateX(calc(var(--index) * 100%)); transition: transform var(--duration-spring) var(--ease-spring); }\n:global([data-theme=\"dark\"]) .thumb { background: color-mix(in oklab, var(--foreground) 20%, var(--surface-raised)); box-shadow: none; }\n.segment { display: grid; place-items: center; margin: 0; padding: 0; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-muted); cursor: pointer; outline: none; -webkit-tap-highlight-color: transparent; transition: color var(--duration-fast) var(--ease-standard); }\n.segment .dot { --dot-size: 9px; --presence-surface: var(--surface-raised); }\n@media (hover: hover) and (pointer: fine) { .segment:hover { color: var(--text-secondary); } }\n.segment[aria-checked=\"true\"] { color: var(--foreground); }\n\n.spinner { animation: user-menu-spin .8s linear infinite; }\n@keyframes user-menu-spin { to { transform: rotate(360deg); } }\n\n/* Below 640px the same content rises as a bottom sheet with larger rows. */\n.scrim { position: fixed; inset: 0; z-index: 70; background: oklch(0% 0 0 / .32); -webkit-tap-highlight-color: transparent; }\n.sheet { --row: 48px; --inset: 10px; position: fixed; top: auto; right: 0; bottom: 0; left: 0; z-index: 71; width: auto; max-width: none; max-height: 88dvh; overflow-y: auto; overscroll-behavior: contain; padding: 8px var(--inset) calc(12px + env(safe-area-inset-bottom)); border-bottom: 0; border-radius: var(--radius-panel) var(--radius-panel) 0 0; }\n.handle { display: block; width: 36px; height: 5px; margin: 0 auto 10px; border-radius: var(--radius-pill); background: var(--border-strong); cursor: grab; }\n.sheet .header { padding: 4px 8px 12px; }\n.sheet .item, .sheet .rowLabel { font-size: var(--text-base); }\n.sheet .row { height: 56px; }\n.sheet .segments { --segment: 44px; height: 38px; }\n.sheet .keys { display: none; }\n\n@media (prefers-reduced-motion: reduce) {\n .trigger, .chevron, .dot, .dot::before, .dot::after, .segment, .thumb, .item, .icon { transition: none; }\n .spinner { animation-duration: 2.4s; }\n}\n"}],"docs":"Docs and live preview: https://uiarc.dev/components/user-menu","meta":{"tier":"free","kind":"component","docs":"https://uiarc.dev/components/user-menu","markdown":"https://uiarc.dev/components/user-menu/markdown","tags":["motion","product"]}}