# User Menu

> An avatar-triggered account menu with a profile header, links, a theme toggle and sign out.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/user-menu
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/user-menu.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/user-menu-demo.tsx"
"use client"

import * as React from "react"
import { CreditCard, Settings, User } from "lucide-react"

import { UserMenu, type UserMenuTheme } from "@/components/user-menu"

export default function UserMenuDemo() {
  const [last, setLast] = React.useState("Nothing yet")
  const [theme, setTheme] = React.useState<UserMenuTheme>("system")

  return (
    <div className="flex h-[27rem] w-full max-w-xl flex-col gap-4">
      <header className="flex h-14 shrink-0 items-center justify-between gap-3 rounded-xl border bg-card px-4">
        <span className="truncate text-sm font-semibold">Northwind Console</span>
        <UserMenu
          defaultOpen
          user={{ name: "Ada Lovelace", email: "ada@northwind.dev", initials: "AL" }}
          links={[
            { label: "Profile", icon: User, shortcut: "⇧⌘P", onSelect: () => setLast("Profile") },
            { label: "Billing", icon: CreditCard, shortcut: "⌘B", onSelect: () => setLast("Billing") },
            { label: "Settings", icon: Settings, shortcut: "⌘,", onSelect: () => setLast("Settings") },
          ]}
          theme={theme}
          onThemeChange={(next) => {
            setTheme(next)
            setLast(`Theme: ${next}`)
          }}
          onSignOut={() => setLast("Signed out")}
        />
      </header>
      <p className="mt-auto text-center text-xs text-muted-foreground">
        Last action: <span className="font-medium text-foreground">{last}</span> · theme {theme}
      </p>
    </div>
  )
}
```

## Source

```tsx title="components/user-menu.tsx"
"use client"

import * as React from "react"
import { LogOut, Monitor, Moon, Sun } from "lucide-react"

import { cn } from "@/lib/utils"

export type UserMenuTheme = "light" | "dark" | "system"

export interface UserMenuLink {
  label: string
  icon?: React.ComponentType<{ className?: string }>
  shortcut?: string
  href?: string
  onSelect?: () => void
}

export interface UserMenuProps extends Omit<React.ComponentProps<"div">, "children"> {
  user: { name: string; email: string; initials?: string }
  links: UserMenuLink[]
  /** Controlled theme */
  theme?: UserMenuTheme
  defaultTheme?: UserMenuTheme
  onThemeChange?: (theme: UserMenuTheme) => void
  onSignOut?: () => void
  align?: "start" | "end"
  /** Render the menu open on mount (without moving focus) */
  defaultOpen?: boolean
}

const themes: { value: UserMenuTheme; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
  { value: "light", label: "Light", icon: Sun },
  { value: "dark", label: "Dark", icon: Moon },
  { value: "system", label: "System", icon: Monitor },
]

const itemClass =
  "flex h-9 w-full cursor-default items-center gap-2.5 rounded-md px-2 text-left text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground"

function initialsOf(name: string) {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase())
    .join("")
}

export function UserMenu({
  user,
  links,
  theme: themeProp,
  defaultTheme = "system",
  onThemeChange,
  onSignOut,
  align = "end",
  defaultOpen = false,
  className,
  ...props
}: UserMenuProps) {
  // "focus" moves focus into the menu on open; "quiet" only renders it
  const [open, setOpen] = React.useState<"closed" | "focus" | "quiet">(defaultOpen ? "quiet" : "closed")
  const [innerTheme, setInnerTheme] = React.useState(defaultTheme)
  const theme = themeProp ?? innerTheme
  const root = React.useRef<HTMLDivElement>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const menu = React.useRef<HTMLDivElement>(null)
  const typed = React.useRef({ text: "", at: 0 })
  const id = React.useId()
  const initials = user.initials ?? initialsOf(user.name)
  const isOpen = open !== "closed"

  const options = () => Array.from(menu.current?.querySelectorAll<HTMLElement>("[data-um-item]") ?? [])

  React.useEffect(() => {
    if (open === "focus") menu.current?.querySelector<HTMLElement>("[data-um-item]")?.focus()
  }, [open])

  React.useEffect(() => {
    if (!isOpen) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen("closed")
    }
    // Escape from inside is handled by the menu; this covers an open menu that never received focus
    const onDocumentKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !root.current?.contains(document.activeElement)) setOpen("closed")
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onDocumentKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onDocumentKeyDown)
    }
  }, [isOpen])

  const close = () => {
    setOpen("closed")
    button.current?.focus()
  }

  const chooseTheme = (next: UserMenuTheme) => {
    setInnerTheme(next)
    onThemeChange?.(next)
  }

  const onMenuKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
    const all = options()
    const current = document.activeElement as HTMLElement | null
    const index = current ? all.indexOf(current) : -1
    const focusAt = (to: number) => all[(to + all.length) % all.length]?.focus()
    switch (event.key) {
      case "ArrowDown":
        focusAt(index + 1)
        break
      case "ArrowUp":
        focusAt(index < 0 ? -1 : index - 1)
        break
      case "Home":
        focusAt(0)
        break
      case "End":
        focusAt(-1)
        break
      case "ArrowRight":
      case "ArrowLeft": {
        // Inside the theme segmented control the horizontal arrows move between segments
        if (!current?.hasAttribute("data-um-seg")) return
        const segments = all.filter((el) => el.hasAttribute("data-um-seg"))
        const at = segments.indexOf(current)
        segments[(at + (event.key === "ArrowRight" ? 1 : -1) + segments.length) % segments.length]?.focus()
        break
      }
      case "Escape":
        close()
        break
      case "Tab":
        close()
        return
      case " ":
        // Anchors don't activate on Space natively
        if (current instanceof HTMLAnchorElement) current.click()
        else return
        break
      default: {
        if (event.key.length !== 1 || !/\S/.test(event.key) || event.metaKey || event.ctrlKey || event.altKey) return
        const now = event.timeStamp
        typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + event.key.toLowerCase(), at: now }
        all.find((el) => el.textContent?.trim().toLowerCase().startsWith(typed.current.text))?.focus()
        return
      }
    }
    event.preventDefault()
  }

  const selectLink = (link: UserMenuLink) => {
    link.onSelect?.()
    close()
  }

  return (
    <div ref={root} className={cn("relative inline-block", className)} {...props}>
      <button
        ref={button}
        id={`${id}-trigger`}
        type="button"
        aria-label={`User menu: ${user.name} ${initials}`}
        aria-haspopup="menu"
        aria-expanded={isOpen}
        aria-controls={isOpen ? id : undefined}
        onClick={() => setOpen(isOpen ? "closed" : "focus")}
        onKeyDown={(event) => {
          if (event.key === "ArrowDown" || event.key === "ArrowUp") {
            event.preventDefault()
            setOpen("focus")
          }
        }}
        className="flex size-10 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground outline-none ring-offset-2 ring-offset-background transition-shadow hover:opacity-90 focus-visible:ring-4 focus-visible:ring-ring/40 aria-expanded:ring-2 aria-expanded:ring-primary/40"
      >
        {initials}
      </button>
      {isOpen && (
        <div
          ref={menu}
          id={id}
          role="menu"
          aria-labelledby={`${id}-trigger`}
          onKeyDown={onMenuKeyDown}
          className={cn(
            "absolute top-full z-30 mt-2 w-72 max-w-[calc(100vw-2rem)] rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 slide-in-from-top-1 motion-reduce:animate-none",
            align === "end" ? "right-0 origin-top-right" : "left-0 origin-top-left"
          )}
        >
          <div className="flex items-center gap-3 px-2 py-2.5">
            <span
              aria-hidden
              className="flex size-10 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-semibold text-primary-foreground"
            >
              {initials}
            </span>
            <div className="min-w-0">
              <p className="truncate text-sm font-medium">{user.name}</p>
              <p className="truncate text-xs text-muted-foreground">{user.email}</p>
            </div>
          </div>
          <div role="separator" className="-mx-1 my-1 h-px bg-border" />

          {links.map((link) => {
            const Icon = link.icon
            const content = (
              <>
                {Icon && <Icon className="size-4 opacity-70" />}
                <span className="min-w-0 flex-1 truncate">{link.label}</span>
                {link.shortcut && <span className="text-xs tracking-widest text-muted-foreground">{link.shortcut}</span>}
              </>
            )
            return link.href ? (
              <a
                key={link.label}
                href={link.href}
                role="menuitem"
                data-um-item
                tabIndex={-1}
                onPointerMove={(event) => event.currentTarget.focus()}
                onClick={() => selectLink(link)}
                className={itemClass}
              >
                {content}
              </a>
            ) : (
              <button
                key={link.label}
                type="button"
                role="menuitem"
                data-um-item
                tabIndex={-1}
                onPointerMove={(event) => event.currentTarget.focus()}
                onClick={() => selectLink(link)}
                className={itemClass}
              >
                {content}
              </button>
            )
          })}

          <div role="separator" className="-mx-1 my-1 h-px bg-border" />
          <div role="group" aria-label="Theme" className="px-1 pt-1 pb-1.5">
            <p role="presentation" className="px-1 pb-1.5 text-xs font-medium text-muted-foreground">
              Theme
            </p>
            <div className="grid grid-cols-3 gap-1 rounded-lg bg-muted p-1">
              {themes.map(({ value, label, icon: Icon }) => (
                <button
                  key={value}
                  type="button"
                  role="menuitemradio"
                  aria-checked={theme === value}
                  data-um-item
                  data-um-seg
                  tabIndex={-1}
                  onClick={() => chooseTheme(value)}
                  className="flex h-8 min-w-0 items-center justify-center gap-1.5 rounded-md px-1 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-4 focus-visible:ring-ring/40 aria-checked:bg-background aria-checked:text-foreground aria-checked:shadow-sm"
                >
                  <Icon className="size-3.5 shrink-0" />
                  <span className="truncate">{label}</span>
                </button>
              ))}
            </div>
          </div>

          <div role="separator" className="-mx-1 my-1 h-px bg-border" />
          <button
            type="button"
            role="menuitem"
            data-um-item
            tabIndex={-1}
            onPointerMove={(event) => event.currentTarget.focus()}
            onClick={() => {
              onSignOut?.()
              close()
            }}
            className={cn(itemClass, "text-destructive focus:bg-destructive/10 focus:text-destructive")}
          >
            <LogOut className="size-4 opacity-80" />
            <span className="flex-1">Sign out</span>
          </button>
        </div>
      )}
    </div>
  )
}
```
