User menu
An avatar that opens the account menu: name, email, Profile, Settings, Theme, Sign out.
npx shadcn@latest add https://ui.goodtabs.co/r/user-menu.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/user-menu
goodtabs
Initials in a header bar. Profile, Settings, a Theme submenu and Sign out.
With an image
Dashboard
An avatar image with the initials as fallback while it loads.
Minimal, no theme
Without onThemeChange the Theme submenu is left out.
Usage
import { UserMenu } from "@/components/ui/user-menu"
<UserMenu
user={{ name: "Mira Chen", email: "mira@goodtabs.co", image: session.user.image }}
onSelect={(action) => action === "signout" ? signOut() : router.push(`/${action}`)}
theme={theme}
onThemeChange={setTheme}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| user | { name: string; email: string; image?: string } | Who is signed in. Initials come from the first two words of the name. | |
| onSelect | (action: "profile" | "settings" | "signout") => void | Called when Profile, Settings or Sign out is chosen. | |
| theme | "light" | "dark" | "system" | "system" | Current theme, marked in the Theme submenu. |
| onThemeChange | (theme: "light" | "dark" | "system") => void | Called with the chosen theme. The Theme submenu shows only when this is passed. | |
| align | "start" | "center" | "end" | "end" | Where the menu lines up with the avatar. |
Source
user-menu/user-menu.tsx
"use client";
import * as React from "react";
import { LogOutIcon, MonitorIcon, MoonIcon, SettingsIcon, SunIcon, SunMoonIcon, UserIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type UserMenuUser = {
/** Full name, shown in the header and used for the initials. */
name: string;
/** Shown under the name in the muted tone. */
email: string;
/** Avatar image. Initials show while it loads or if it fails. */
image?: string;
};
export type UserMenuAction = "profile" | "settings" | "signout";
export type UserMenuTheme = "light" | "dark" | "system";
type UserMenuProps = Omit<React.ComponentProps<"button">, "onSelect"> & {
/** Who is signed in. */
user: UserMenuUser;
/** Called with "profile", "settings" or "signout". */
onSelect?: (action: UserMenuAction) => void;
/** Current theme. The Theme submenu shows only when onThemeChange is passed. */
theme?: UserMenuTheme;
/** Called with the chosen theme. */
onThemeChange?: (theme: UserMenuTheme) => void;
/** Where the menu lines up with the avatar. */
align?: "start" | "center" | "end";
};
const itemClass =
"h-8 gap-2 rounded-sm px-2 py-0 focus:bg-muted focus:text-foreground data-open:bg-muted data-open:text-foreground";
const themes: { value: UserMenuTheme; label: string; icon: React.ReactNode }[] = [
{ value: "light", label: "Light", icon: <SunIcon /> },
{ value: "dark", label: "Dark", icon: <MoonIcon /> },
{ value: "system", label: "System", icon: <MonitorIcon /> },
];
/** First letters of the first two words, upper case. */
export function initials(name: string) {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((w) => w.charAt(0).toUpperCase())
.join("");
}
/**
* An avatar that opens the account menu: name and email, Profile, Settings,
* an optional Theme submenu with radio items, then Sign out.
*/
export function UserMenu({
user,
onSelect,
theme = "system",
onThemeChange,
align = "end",
className,
...props
}: UserMenuProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={`Account, ${user.name}`}
data-slot="user-menu"
className={cn(
"rounded-full transition-opacity duration-100 ease-out outline-none hover:opacity-85 focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50",
className,
)}
{...props}
>
<Avatar>
{user.image && <AvatarImage src={user.image} alt="" />}
<AvatarFallback className="text-xs font-medium text-foreground">{initials(user.name)}</AvatarFallback>
</Avatar>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} className="w-auto min-w-56 rounded-md border ring-0">
<DropdownMenuLabel className="flex flex-col gap-0 px-2 py-1.5 font-normal">
<span className="truncate text-sm font-medium text-foreground">{user.name}</span>
<span className="truncate text-xs text-muted-foreground">{user.email}</span>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className={itemClass} onSelect={() => onSelect?.("profile")}>
<UserIcon className="text-muted-foreground" aria-hidden="true" />
Profile
</DropdownMenuItem>
<DropdownMenuItem className={itemClass} onSelect={() => onSelect?.("settings")}>
<SettingsIcon className="text-muted-foreground" aria-hidden="true" />
Settings
</DropdownMenuItem>
{onThemeChange && (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={itemClass}>
<SunMoonIcon className="text-muted-foreground" aria-hidden="true" />
Theme
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="min-w-36 rounded-md border ring-0">
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => onThemeChange(v as UserMenuTheme)}>
{themes.map((t) => (
<DropdownMenuRadioItem key={t.value} value={t.value} className={cn(itemClass, "pr-8")}>
<span className="shrink-0 text-muted-foreground [&_svg]:size-4" aria-hidden="true">
{t.icon}
</span>
{t.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem className={itemClass} onSelect={() => onSelect?.("signout")}>
<LogOutIcon className="text-muted-foreground" aria-hidden="true" />
Sign out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
For AI agents
Hand your agent this page, the markdown at /components/user-menu/prompt.md, or the Copy AI prompt button above.
When to use
- The far right of an app header or the bottom of a sidebar, once someone is signed in
- When account actions are few and fixed: profile, settings, theme, sign out
- Anywhere the avatar alone should be enough to find the account
When not to use
- Signed out states; show a Sign in button instead
- Switching between workspaces or organisations; that needs its own switcher
- Long settings lists; link to a settings page from here
Agent notes
- It takes functions (onSelect, onThemeChange), so the component rendering it must be a client component
- Wire theme and onThemeChange to your theme provider, for example next-themes useTheme; the component holds no theme state
- The trigger is a plain button around shadcn's Avatar, named Account plus the user's name; extra button props spread onto it
- Routes are not built in: handle "profile" and "settings" in onSelect