Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
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") => voidCalled 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") => voidCalled 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