An account menu on the dropdown: avatar or avatar-with-name trigger, a header card with plan, link groups with shortcuts and badges, a theme submenu and sign out.
"use client";
import * as React from "react";
import { CreditCard, LifeBuoy, Settings, UserRound } from "lucide-react";
import { UserMenu, type UserMenuTheme } from "@/components/ballmac/user-menu";
export default function UserMenuDemo() {
const [theme, setTheme] = React.useState<UserMenuTheme>("system");
return (
<div className="flex h-[26rem] w-full max-w-xs items-start justify-center">
<UserMenu
defaultOpen
modal={false}
variant="full"
align="start"
user={{ name: "Jordan Rivera", email: "jordan@acme.example", plan: "Pro", status: "online" }}
groups={[
[
{ label: "Profile", href: "#profile", icon: <UserRound />, shortcut: "⇧⌘P" },
{ label: "Billing", href: "#billing", icon: <CreditCard />, badge: "Due" },
{ label: "Settings", href: "#settings", icon: <Settings />, shortcut: "⌘," },
],
[{ label: "Help and support", href: "#help", icon: <LifeBuoy /> }],
]}
theme={theme}
onThemeChange={setTheme}
onSignOut={() => undefined}
/>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/user-menuInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/avatar @ballmac/dropdown-menu @ballmac/i18nCopy the source into your project.
components/ballmac/user-menu.tsx// Ballmac UI: User Menu. https://ui.ballmac.com/components/user-menu "use client"; import * as React from "react"; import { ChevronsUpDown, LogOut, Monitor, Moon, Palette, Sun } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage, type AvatarProps } from "@/components/ballmac/avatar"; import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuShortcut, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ballmac/dropdown-menu"; import { cn } from "@/lib/utils"; import { useMessages, defineMessage } from "@/lib/ballmac/i18n"; type UserMenuUser = { name: string; email: string; /** Avatar image URL. Initials show while it loads or if it fails. */ image?: string; /** Plan label shown as a badge in the menu header, for example "Pro". */ plan?: string; /** Presence dot on the avatar. */ status?: AvatarProps["status"]; }; type UserMenuItem = { label: string; /** Navigates when set. */ href?: string; /** Runs when chosen. */ onSelect?: () => void; icon?: React.ReactNode; /** Keys shown at the end. Display only. */ shortcut?: string; /** Small text at the end, for example "3 new". */ badge?: string; }; type UserMenuTheme = "light" | "dark" | "system"; type UserMenuProps = Omit<React.ComponentProps<"button">, "children"> & { user: UserMenuUser; /** Groups of items, shown in order with dividers between them. */ groups?: UserMenuItem[][]; /** Current theme. Adds a Theme submenu when `onThemeChange` is also set. */ theme?: UserMenuTheme; /** Called with the chosen theme. */ onThemeChange?: (theme: UserMenuTheme) => void; /** Adds a destructive "Sign out" item at the end. */ onSignOut?: () => void; /** Trigger style: just the avatar, or avatar with name and email (for sidebars). */ variant?: "avatar" | "full"; /** Menu alignment relative to the trigger. */ align?: "start" | "center" | "end"; /** Side of the trigger the menu opens on. */ side?: "top" | "right" | "bottom" | "left"; /** Open state, for controlled use. */ open?: boolean; /** Initial open state. */ defaultOpen?: boolean; /** Called when the menu opens or closes. */ onOpenChange?: (open: boolean) => void; /** Trap interaction inside the menu while open. Turn off to keep the page usable, for example in a docs preview. */ modal?: boolean; }; function initials(name: string) { return name .split(/\s+/) .filter(Boolean) .slice(0, 2) .map((p) => p.charAt(0).toUpperCase()) .join(""); } function UserAvatar({ user, size }: { user: UserMenuUser; size?: AvatarProps["size"] }) { return ( <Avatar size={size} status={user.status}> {user.image && <AvatarImage src={user.image} alt="" />} <AvatarFallback>{initials(user.name)}</AvatarFallback> </Avatar> ); } const THEMES = [ { value: "light", label: defineMessage("user-menu.THEMES.0", "Light"), icon: Sun }, { value: "dark", label: defineMessage("user-menu.THEMES.1", "Dark"), icon: Moon }, { value: "system", label: defineMessage("user-menu.THEMES.2", "System"), icon: Monitor }, ] as const; /** * An account menu: avatar (or avatar with name) opens a card-style header, your links, an optional theme picker and sign out. * Built on the dropdown menu, so arrow keys, typeahead and Escape work, and the avatar's alt text is the person's name. */ function UserMenu({ user, groups = [], theme, onThemeChange, onSignOut, variant = "avatar", align = "end", side = "bottom", open, defaultOpen, onOpenChange, modal = true, className, ...props }: UserMenuProps) { const msg = useMessages() return ( <DropdownMenu open={open} defaultOpen={defaultOpen} onOpenChange={onOpenChange} modal={modal}> <DropdownMenuTrigger data-slot="user-menu" aria-label={variant === "avatar" ? `Account menu for ${user.name}` : undefined} className={cn( variant === "avatar" ? "rounded-full outline-none transition-shadow hover:ring-4 hover:ring-ring/15 focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:ring-4 data-[state=open]:ring-ring/20" : "flex w-full min-w-0 items-center gap-3 rounded-xl border bg-background p-2 pe-3 text-start shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:bg-accent", className, )} {...props} > <UserAvatar user={user} /> {variant === "full" && ( <> <span className="grid min-w-0 flex-1 leading-tight"> <span className="truncate text-sm font-semibold">{user.name}</span> <span className="truncate text-xs text-muted-foreground">{user.email}</span> </span> <ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> </> )} </DropdownMenuTrigger> <DropdownMenuContent align={align} side={side} className="w-72 p-0"> <div className="flex items-center gap-3 border-b bg-gradient-to-br from-chart-1/[0.08] to-transparent p-4"> <UserAvatar user={user} size="lg" /> <div className="grid min-w-0 flex-1 leading-tight"> <span className="truncate text-sm font-semibold">{user.name}</span> <span className="truncate text-xs text-muted-foreground">{user.email}</span> </div> {user.plan && ( <span className="shrink-0 rounded-full bg-primary px-2 py-0.5 text-[10px] font-semibold tracking-wide text-primary-foreground uppercase"> {user.plan} </span> )} </div> <div className="p-1"> {groups.map((group, gi) => ( <React.Fragment key={gi}> {gi > 0 && <DropdownMenuSeparator />} <DropdownMenuGroup> {group.map((item) => { const inner = ( <> {item.icon} {item.label} {item.badge && ( <span className="ms-auto rounded-full bg-muted px-1.5 py-px text-[10px] font-semibold text-muted-foreground">{item.badge}</span> )} {item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>} </> ); // createElement keeps `asChild` out of JSX (the shadcn CLI rewrites it for Base UI projects and would break this // Radix-based menu). The item renders as the link itself, so there is one interactive element, not two. return item.href ? ( React.createElement( DropdownMenuItem, { key: item.label, asChild: true, onSelect: item.onSelect }, <a href={item.href}>{inner}</a>, ) ) : ( <DropdownMenuItem key={item.label} onSelect={item.onSelect}> {inner} </DropdownMenuItem> ); })} </DropdownMenuGroup> </React.Fragment> ))} {theme && onThemeChange && ( <> {groups.length > 0 && <DropdownMenuSeparator />} <DropdownMenuSub> <DropdownMenuSubTrigger> <Palette aria-hidden="true" /> Theme <span className="ms-auto pe-1 text-xs text-muted-foreground capitalize">{theme}</span> </DropdownMenuSubTrigger> <DropdownMenuSubContent> <DropdownMenuRadioGroup value={theme} onValueChange={(v) => onThemeChange(v as UserMenuTheme)}> {THEMES.map(({ value, label, icon: Icon }) => ( <DropdownMenuRadioItem key={value} value={value}> <Icon aria-hidden="true" /> {msg.of(label)} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> </DropdownMenuSubContent> </DropdownMenuSub> </> )} {onSignOut && ( <> <DropdownMenuSeparator /> <DropdownMenuItem destructive onSelect={onSignOut}> <LogOut aria-hidden="true" /> {msg("user-menu.signOut", "Sign out")} </DropdownMenuItem> </> )} </div> </DropdownMenuContent> </DropdownMenu> ); } export { UserMenu, type UserMenuProps, type UserMenuUser, type UserMenuItem, type UserMenuTheme };Update the import paths to match your project setup.
Usage
import { UserMenu } from "@/components/ballmac/user-menu"The full example is in the Code tab above.
Examples
Avatar only
Avatar-only trigger for headers. Initials appear when there is no photo.
"use client";
import { UserMenu } from "@/components/ballmac/user-menu";
export default function UserMenuStates() {
return (
<div className="flex items-center gap-4 rounded-xl border bg-card p-3">
<UserMenu user={{ name: "Ana Lima", email: "ana@acme.example", status: "away" }} groups={[[{ label: "Profile", href: "#profile" }]]} onSignOut={() => undefined} />
<p className="text-sm text-muted-foreground">Avatar-only trigger for headers. Initials appear when there is no photo.</p>
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
user* | UserMenuUser | — |
groupsGroups of items, shown in order with dividers between them. | UserMenuItem[][] | [] |
themeCurrent theme. Adds a Theme submenu when `onThemeChange` is also set. | UserMenuTheme | — |
onThemeChangeCalled with the chosen theme. | (theme: UserMenuTheme) => void | — |
onSignOutAdds a destructive "Sign out" item at the end. | () => void | — |
variantTrigger style: just the avatar, or avatar with name and email (for sidebars). | "avatar" | "full" | "avatar" |
alignMenu alignment relative to the trigger. | "start" | "center" | "end" | "end" |
sideSide of the trigger the menu opens on. | "top" | "right" | "bottom" | "left" | "bottom" |
openOpen state, for controlled use. | boolean | — |
defaultOpenInitial open state. | boolean | — |
onOpenChangeCalled when the menu opens or closes. | (open: boolean) => void | — |
modalTrap interaction inside the menu while open. Turn off to keep the page usable, for example in a docs preview. | boolean | true |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space / ArrowDown | Opens the menu |
| Arrow keys and typeahead | Move between items |
| Escape | Closes and returns focus |
| Screen readers | The avatar button names the person; links are real links |
Use with AI
user {name,email,image,plan,status}, groups: UserMenuItem[][], theme/onThemeChange, onSignOut, variant avatar | full. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI User Menu (@ballmac/user-menu) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Headers and sidebar footers
- Account access with theme and sign out
Not for
- Switching workspaces; use team-switcher
- Generic action menus; use dropdown-menu
Registry JSON: https://ui.ballmac.com/r/user-menu.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.
Dropdown Menu
An action menu on a trigger with checkbox and radio items, submenus, shortcut hints and destructive rows, built on Radix for full keyboard and typeahead support.
Team Switcher
A workspace picker: the current team on a button, the others in a radio menu with logos and descriptions, an add-team row, and a compact icon mode.
Billing Card
A subscription summary with plan and price, status, next charge, payment method, seat usage and recent invoices, including failed-payment and trial states.