User menu
The signed-in account: an avatar, or a full name row, that opens the account menu.
The avatar trigger carries no text, so it is named after the person it belongs to — "Account menu for Ada Lovelace" — while the full trigger takes its name from the name it shows. The full trigger is a ghost button sized to fit a sidebar footer: avatar, name, and a ChevronsUpDown. Initials come from getInitials(user.name) unless user.fallback overrides them. Items are plain entries by default and links when they carry an href; a shortcut is written the way KbdShortcut takes it, so "mod+," reads as ⌘, on mac and Ctrl+, elsewhere. The log out entry exists only when onSignOut does. The trigger carries data-slot="user-menu" because it is the part always in the DOM; the popup is data-slot="user-menu-content".
Install
npx shadcn@latest add @vibra/user-menuNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { CommandIcon, LifeBuoyIcon, SettingsIcon, UserIcon } from "lucide-react"
import { UserMenu, type UserMenuItem } from "@/components/ui/user-menu"
const USER = {
name: "Ada Lovelace",
email: "ada@northwind.example",
plan: "Pro",
}
export default function UserMenuDemo() {
const [picked, setPicked] = React.useState<string | null>(null)
const items: UserMenuItem[] = [
{ label: "Profile", icon: <UserIcon />, onSelect: () => setPicked("Profile") },
{
label: "Workspace settings",
icon: <SettingsIcon />,
shortcut: "mod+,",
onSelect: () => setPicked("Workspace settings"),
},
{
label: "Command palette",
icon: <CommandIcon />,
shortcut: "mod+k",
separatorBefore: true,
onSelect: () => setPicked("Command palette"),
},
{ label: "Support", icon: <LifeBuoyIcon />, onSelect: () => setPicked("Support") },
]
return (
<div className="flex w-full max-w-sm flex-col gap-4">
<div className="flex h-12 items-center justify-between gap-3 rounded-lg border px-3">
<span className="text-sm font-medium">Northwind</span>
<UserMenu user={USER} items={items} onSignOut={() => setPicked("Log out")} />
</div>
<div className="rounded-lg border p-1">
<UserMenu
user={USER}
trigger="full"
align="start"
items={items}
onSignOut={() => setPicked("Log out")}
/>
</div>
<p className="text-sm text-muted-foreground">
{picked ? `Picked ${picked}.` : "The same menu, from an avatar and from a sidebar footer."}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| user.name | string | — | The signed-in person; its initials fill the avatar. |
| user.email | string | — | A quiet second line in the account block. |
| user.src | string | — | Avatar image; the initials show while it loads or fails. |
| user.fallback | string | initials of name | Overrides the initials shown in the avatar. |
| user.plan | string | — | A short plan or role, shown as a badge beside the name. |
| items | UserMenuItem[] | — | The entries above the log out row; none by default. |
| onSignOut | () => void | — | Adds a "Log out" entry with a LogOut icon at the bottom. |
| align | "start" | "end" | "end" | Which edge of the trigger the menu lines up with. |
| trigger | "avatar" | "full" | "avatar" | avatar is a round icon button; full is a name row for a sidebar footer. |
| className | string | — | Classes for the trigger button, which is the root; the remaining button props are spread onto it too. |
| UserMenuItem.label | React.ReactNode | — | The entry itself. |
| UserMenuItem.icon | React.ReactNode | — | Sits before the label; sized to 4 unless it sets its own size. |
| UserMenuItem.onSelect | () => void | — | Runs on click. |
| UserMenuItem.href | string | — | Renders the entry as a link; onSelect still runs. |
| UserMenuItem.shortcut | string | — | Written the way KbdShortcut takes it, e.g. "mod+,". |
| UserMenuItem.separatorBefore | boolean | false | Draws a hairline above the entry; ignored on the first one. |
Dependencies
Source
"use client"
import * as React from "react"
import { ChevronsUpDownIcon, LogOutIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { KbdShortcut } from "@/components/ui/kbd-shortcut"
import { getInitials } from "@/lib/format"
export type UserMenuItem = {
label: React.ReactNode
icon?: React.ReactNode
/** Runs on click; pair it with href to both navigate and record the choice. */
onSelect?: () => void
/** Renders the item as a link. */
href?: string
/** Written the way KbdShortcut takes it, e.g. "mod+," — "mod" is ⌘ on mac, Ctrl elsewhere. */
shortcut?: string
separatorBefore?: boolean
}
// The trigger button is the root: className and the rest of the props land on
// whichever of the two shapes `trigger` selects. className is re-declared as a
// plain string, and `trigger` names the shape rather than the button's content.
export type UserMenuProps = Omit<
React.ComponentProps<typeof Button>,
"className" | "variant" | "size" | "children" | "render" | "aria-label"
> & {
user: {
name: string
email?: string
src?: string
/** Overrides the initials taken from the name. */
fallback?: string
/** A short plan or role, shown as a badge beside the name. */
plan?: string
}
items?: UserMenuItem[]
/** Adds a "Log out" entry at the bottom of the menu. */
onSignOut?: () => void
align?: "start" | "end"
trigger?: "avatar" | "full"
className?: string
}
/** The signed-in account: an avatar (or a full name row) that opens the account menu. */
function UserMenu({
user,
items,
onSignOut,
align = "end",
trigger = "avatar",
className,
...props
}: UserMenuProps) {
const initials = user.fallback ?? getInitials(user.name)
// Decorative everywhere it appears: the trigger names itself, and the name
// is right beside it in the menu — initials would only add "A L" to both.
const avatar = (size: "sm" | "default") => (
<Avatar size={size} aria-hidden="true">
{user.src ? <AvatarImage src={user.src} alt="" /> : null}
<AvatarFallback>{initials}</AvatarFallback>
</Avatar>
)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
trigger === "full" ? (
<Button
type="button"
variant="ghost"
data-slot="user-menu"
data-trigger="full"
className={cn("h-auto w-full justify-start gap-2 px-2 py-1.5", className)}
{...props}
/>
) : (
<Button
type="button"
variant="ghost"
size="icon"
data-slot="user-menu"
data-trigger="avatar"
// The avatar carries no text, so the trigger says whose account
// this is rather than leaving a reader with an unnamed button.
aria-label={`Account menu for ${user.name}`}
className={cn("rounded-full", className)}
{...props}
/>
)
}
>
{trigger === "full" ? (
<>
{avatar("default")}
<span className="min-w-0 flex-1 truncate text-start text-sm font-medium">
{user.name}
</span>
<ChevronsUpDownIcon className="shrink-0 text-muted-foreground" />
</>
) : (
avatar("sm")
)}
</DropdownMenuTrigger>
{/* COUPLED TO registry/vibra/ui/item.tsx: an xs Item resets its padding
inside [data-slot=dropdown-menu-content], and renaming the slot here
takes that name away. The menu's rows are its own and it accepts no
arbitrary content, so nothing in it can be an Item; if that ever
changes, move this slot to a wrapper instead. The trigger keeps the
bare "user-menu" slot because it is the part always in the DOM. */}
<DropdownMenuContent data-slot="user-menu-content" align={align} className="w-60">
<div data-slot="user-menu-account" className="flex items-center gap-2 px-1.5 py-1.5">
{avatar("default")}
<div className="grid min-w-0 flex-1 gap-0.5 leading-tight">
<div className="flex min-w-0 items-center gap-1.5">
<span className="truncate text-sm font-medium">{user.name}</span>
{user.plan ? (
<Badge variant="secondary" className="shrink-0">
{user.plan}
</Badge>
) : null}
</div>
{user.email ? (
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
) : null}
</div>
</div>
{items?.length ? <DropdownMenuSeparator /> : null}
{items?.map((item, index) => (
<React.Fragment key={index}>
{item.separatorBefore && index > 0 ? <DropdownMenuSeparator /> : null}
<DropdownMenuItem
data-slot="user-menu-item"
onClick={item.onSelect}
render={item.href ? <a href={item.href} /> : undefined}
>
{item.icon}
{item.label}
{item.shortcut ? (
<KbdShortcut keys={item.shortcut} size="sm" className="ms-auto" />
) : null}
</DropdownMenuItem>
</React.Fragment>
))}
{onSignOut ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem data-slot="user-menu-sign-out" onClick={onSignOut}>
<LogOutIcon className="text-muted-foreground" />
Log out
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)
}
export { UserMenu }