User menu

Your account, settings, theme, and sign out behind the avatar. Opens as a bottom sheet on phones.

import { UserMenu } from "@sagui/ui";
Markdown

When to use

  • The single account entry point in an app top bar.
  • Menus that combine presence status, theme preference, account links, and sign out.
  • Apps that want async sign out with a spinner in the menu.

When not to use

  • Use dropdown-menu for generic command lists.
  • Use theme-switch for a standalone light and dark toggle.
  • Use workspace-sidebar when account and workspace switching live in a side navigation.

Installation

Install the package and import the styles once. The installation guide covers the Tailwind setup.

$ npm install @sagui/ui

Usage

example.tsx
import { UserMenu } from "@sagui/ui";
import { CreditCard, Settings, UserRound } from "lucide-react";

export function TopBarAccount({ user }: { user: { name: string; email: string } }) {
  return (
    <UserMenu
      user={{ ...user, plan: "Pro" }}
      showName
      onThemeChange={setThemePreference}
      items={[
        { label: "Profile", icon: <UserRound size={16} />, onSelect: openProfile },
        { label: "Settings", icon: <Settings size={16} />, keys: ["⌘", ","], onSelect: openSettings },
        { label: "Billing", icon: <CreditCard size={16} />, onSelect: openBilling },
      ]}
      onSignOut={() => signOut()}
    />
  );
}

Examples

With the name

showName shows the name and a chevron beside the avatar from 640px up. Phones always show the avatar alone.

Loading demo

Status and theme

Passing status or onStatusChange adds the presence dot and a status switch. theme and onThemeChange report the choice; applying it to the page is up to the app.

Loading demo

API reference

UserMenu

Account menu behind an avatar trigger: a compact identity header, a short group of account links, an inline theme switch, and sign out. Below 640px it opens as a bottom sheet.

Prop Type Default Description
user (required) UserMenuUser – { name, email, plan?, avatarSrc?, avatarSrcSet? }. Falls back to initials without a photo. plan shows as a small badge in the primary color.
items UserMenuItem[] [] Account destinations: { label, icon?, keys?, onSelect? }. Keep it to three or four. keys show quiet shortcut hints such as ["⌘", ","].
showName boolean false Shows the name and a chevron beside the avatar from 640px up. Phones always show the avatar alone.
theme "light" | "dark" | "system" – Controlled theme preference.
defaultTheme "light" | "dark" | "system" "system" Initial theme when uncontrolled.
onThemeChange (theme: ThemePreference) => void – Reports the choice. Applying it to the page is up to the app. The menu stays open.
showTheme boolean true Shows the inline light, dark, and system switch.
status "available" | "busy" | "away" – Controlled presence status. Passing status or onStatusChange adds the presence dot and an inline status switch.
defaultStatus "available" | "busy" | "away" "available" Initial status when uncontrolled.
onStatusChange (status: UserStatus) => void – Called when a status is chosen. The menu stays open.
onSignOut () => void | Promise<unknown> – Sign out handler. A returned promise keeps the menu open with a spinner until it settles.
signOutKeys string[] – Shortcut hint for the sign out item.
open boolean – Controlled open state.
defaultOpen boolean false Initial open state when uncontrolled. A menu that starts open does not take focus.
onOpenChange (open: boolean) => void – Called when the menu opens or closes.
align "start" | "center" | "end" "end" Panel alignment against the trigger.
portal boolean true Renders the desktop panel on the body. Pass false to keep it inside the trigger wrapper, for example in a contained preview. The mobile sheet always uses the body.
ref Ref<HTMLButtonElement> – The trigger button, for returning focus.
className string – Extra class on the trigger.

PresenceDot

The status mark on its own, for avatars elsewhere in the app. Each status has its own shape as well as color.

Prop Type Default Description
status (required) "available" | "busy" | "away" | "offline" – Status to show.
className string – Extra class for positioning.

Keyboard interactions

Keys Action
EnterorSpaceorArrowDown Opens the menu from the avatar and moves to the first row.
ArrowUp Opens the menu from the avatar and moves to the last row.
ArrowDownorArrowUp Moves between rows, looping at the ends. The highlight follows.
HomeorEnd Moves to the first or last row.
A to Z Typeahead jumps to the next row starting with the typed letters.
ArrowLeftorArrowRight Chooses the previous or next option in the theme or status switch.
EnterorSpace Activates the row, or chooses a theme or status without closing.
EscapeorTab Closes the menu and returns focus to the avatar.

Accessibility

  • The trigger has aria-haspopup="menu" and aria-expanded, and is labelled "Account menu, " plus the status when one is shown.
  • Rows are menuitem buttons; the theme and status switches are labelled groups of menuitemradio buttons with aria-checked.
  • No focus rings are drawn: the highlight marks the keyboard position. Outside press, the scrim, and swipe down close the menu.
  • Sign out sets aria-busy while an async handler runs; shortcut hints are aria-hidden.

Motion

  • The panel scales and fades out of the avatar on a spring that never overshoots; rows fade in with a short stagger, and closing is a quick fade.
  • One highlight glides between rows for the pointer and the keyboard; the switch thumb slides between options.
  • On phones the sheet rises from the bottom edge and follows a downward drag, closing past a short distance or a quick flick.
  • Reduced motion opens and closes with a short fade, jumps the highlight, and turns off dragging.

Responsive behavior

  • The menu is 17.5rem wide, capped at the viewport width minus 24px, with 12px collision padding.
  • Long names and emails ellipsize instead of widening the menu.
  • The trigger is a fixed 40px avatar, and the theme row keeps a 44px minimum height for touch.

Performance

  • Content renders only while open; each opening gets a fresh layout namespace so shared indicators do not animate from stale positions.
  • The avatar image decodes async and falls back to initials on error.

Notes

  • Use as the single account entry point in an app top bar. For generic command lists use dropdown-menu; for a standalone light/dark toggle use theme-switch.
  • Keep items to three or four account destinations; the theme switch and sign out are built in.
  • onThemeChange only reports; apply the theme to the document yourself, optionally with a view transition.
  • Return the sign out promise so the item shows progress and the menu closes when it settles.

Also in navigation