← ui

account-menu

ui

sign in with a provider, or the person's face and name opening their menu

npx shadcn add @ag/account-menu
Already installed? Preview an update
npx shadcn add @ag/account-menu --dry-run

Your installed source stays unchanged until you update it. How updates work

Latest changes · 2026-10-15
  • New: AccountMenu, the way in and the account as one control, for any auth library: user ({ name, image, href }, null signed out, undefined not known yet), providers, onSignIn(provider) and onSignOut. One provider is one button with its mark and words (sign in with github); several are one sign in opening a menu of them. Signed in, the face and name open a menu: the profile link, your rows as children, sign out. A press returning a promise shows a spinner, width kept, until the page leaves, user changes or the promise rejects. SignInMenu drops the ways in from any trigger (a vote's, with a reason); ProviderGlyph draws a simple-icons mark. md or lg, plain or glass.
  • The signed-out face's line is the chevron's on screen (1.33 px), at either size; it was 2 px at lg, heavier than everything beside it. The chevron sits concentric with the pill's round end, as the face does with its start, no longer 20 px of air from the edge.
  • icon: the signed-out control's own icon in place of the face, drawn as any icon beside words and concentric with the pill's start, for a site whose own mark is a person.
Updated 2026-10-15 · release notes

also called login, sign in, sign out, logout, user menu, user button, auth, account

shelf controls

uses avatar, button, material, menu

the demo above, verbatim

account-menu.demo.tsx
"use client"

import { LogIn, Settings } from "lucide-react"
import * as React from "react"
import { siGithub, siGoogle, siTelegram } from "simple-icons"
import { Sample } from "@/app/samples"
import {
  AccountMenu,
  type AccountProvider,
  type AccountUser,
  ProviderGlyph,
} from "@/registry/base-nova/ui/account-menu"
import { MaterialProvider } from "@/registry/base-nova/ui/material"
import { DropdownMenuItem } from "@/registry/base-nova/ui/menu"

// #region providers
const GITHUB: AccountProvider<"github"> = {
  id: "github",
  label: "github",
  icon: <ProviderGlyph icon={siGithub} />,
}

const EVERY: AccountProvider<"github" | "google" | "telegram">[] = [
  GITHUB,
  { id: "google", label: "google", icon: <ProviderGlyph icon={siGoogle} /> },
  {
    id: "telegram",
    label: "telegram",
    icon: <ProviderGlyph icon={siTelegram} />,
  },
]
// #endregion

// #region session
/** A stand-in for an auth client: signing in takes a second and lands a user,
 *  signing out takes half of one. Your callbacks call yours (better-auth's
 *  `signIn.social`, `signOut`) and return its promise. */
const wait = (ms: number) => new Promise((done) => setTimeout(done, ms))

const ADRIAN: AccountUser = {
  name: "adrian",
  image: "/adriangalilea.jpg",
  href: "#profile",
}

function useSession(start: AccountUser | null) {
  const [user, setUser] = React.useState<AccountUser | null>(start)
  return {
    user,
    onSignIn: async () => {
      await wait(1000)
      setUser(ADRIAN)
    },
    onSignOut: async () => {
      await wait(500)
      setUser(null)
    },
  }
}
// #endregion

// #region signed-out
function SignedOut() {
  const session = useSession(null)
  return <AccountMenu providers={[GITHUB]} {...session} />
}
// #endregion

// #region several
function Several() {
  const session = useSession(null)
  return <AccountMenu providers={EVERY} {...session} />
}
// #endregion

// #region signed-in
function SignedIn() {
  const session = useSession(ADRIAN)
  return (
    <AccountMenu providers={[GITHUB]} {...session}>
      <DropdownMenuItem>
        <Settings aria-hidden />
        settings
      </DropdownMenuItem>
    </AccountMenu>
  )
}
// #endregion

export default function AccountMenuDemo() {
  return (
    <div className="space-y-8">
      <Sample
        name="glance"
        glance
        title="signed in and out"
        with="providers session signed-out signed-in"
      >
        <div className="flex flex-wrap items-center gap-3">
          <SignedOut />
          <SignedIn />
        </div>
      </Sample>
      <Sample
        name="several"
        title="several ways in"
        description="more than one provider: one sign in, the providers in its menu."
        with="providers session several"
      >
        <Several />
      </Sample>
      <Sample
        name="icon"
        title="its own icon"
        description="for a site whose mark is a person: a face here reads as a second logo."
        with="providers"
      >
        <AccountMenu
          user={null}
          providers={EVERY}
          icon={<LogIn aria-hidden />}
          onSignIn={() => {}}
          onSignOut={() => {}}
        />
      </Sample>
      <Sample name="no-picture" title="no picture" with="providers">
        <AccountMenu
          user={{ name: "gardener" }}
          providers={[GITHUB]}
          onSignIn={() => {}}
          onSignOut={() => {}}
        />
      </Sample>
      <Sample
        name="pending"
        title="not known yet"
        description="user undefined: the signed-out control, waiting, so the bar keeps its height while the session is read."
        with="providers"
      >
        <AccountMenu
          user={undefined}
          providers={[GITHUB]}
          onSignIn={() => {}}
          onSignOut={() => {}}
        />
      </Sample>
      <Sample
        name="glass"
        title="in glass, over a picture"
        with="providers session signed-out several signed-in"
      >
        <MaterialProvider material="glass" scheme="dark">
          <div className="flex flex-wrap items-center gap-3 rounded-xl bg-[url(/lightbox/lake.jpg)] bg-cover bg-center p-6">
            <SignedOut />
            <Several />
            <SignedIn />
          </div>
        </MaterialProvider>
      </Sample>
      <Sample
        name="large"
        title="large, for a tall bar"
        with="providers session"
      >
        <div className="flex flex-wrap items-center gap-3">
          <AccountMenu
            size="lg"
            user={null}
            providers={[GITHUB]}
            onSignIn={() => {}}
            onSignOut={() => {}}
          />
          <AccountMenu
            size="lg"
            user={ADRIAN}
            providers={[GITHUB]}
            onSignIn={() => {}}
            onSignOut={() => {}}
          />
        </div>
      </Sample>
    </div>
  )
}

the source, every file an install writes

account-menu.tsx
"use client"

// THE WAY IN AND THE ACCOUNT, one control in a bar's corner, for any auth library: it
// takes who is signed in and two callbacks, and never imports a client of its own.
//
//   signed out, one provider:  one button, the provider's mark and its words
//                              ("sign in with github"), a press signs in;
//   signed out, several:       one "sign in" button opening a menu of the providers;
//   signed in:                 the person's face and name, opening their menu: the
//                              profile link (`user.href`), the consumer's own rows
//                              (`children`), sign out;
//   not known yet (`user` undefined): the signed-out control, waiting, so the bar
//                              holds its height and the likeliest width.
//
// A press that signs in or out waits on the promise its callback returns: the control
// turns a spinner (Button's `loading`, its width kept) from the press until the page
// leaves or `user` changes, and gives the press back if the promise rejects. A
// callback that resolves without leaving or changing the session must reject (an auth
// client that answers `{ error }` instead of throwing is wrapped to throw). Back from
// the provider through the browser's back/forward cache, the press is given back.
//
// Built from Button, Avatar and the menu's dropdown; plain or glass through
// `material`, its colour scheme through `scheme`. Every part carries a `data-slot`
// (`account-menu`, `account-menu-avatar`, `account-menu-initial`, `account-menu-name`),
// which is also where a host that paints the control before hydration (from a cookie
// of who was signed in) fills the name, the initial and the picture.

import { ChevronDown, CircleUserRound, LogOut, UserRound } from "lucide-react"
import * as React from "react"
import { cn } from "@/lib/utils"
import { Avatar } from "@/registry/base-nova/ui/avatar"
import { Button } from "@/registry/base-nova/ui/button"
import {
  type Material,
  type Scheme,
  useMaterial,
} from "@/registry/base-nova/ui/material"
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuLabel,
  DropdownMenuLinkItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/registry/base-nova/ui/menu"

/** Who is signed in, as the control draws them. */
export type AccountUser = {
  name: string
  /** Their picture; without one, the name's first letter in a disc. */
  image?: string | null
  /** Their page: the menu's first row, "profile". */
  href?: string
}

/** A way in: `id` is what `onSignIn` is called with, `label` its name in words
 *  ("github"), `icon` its mark (`ProviderGlyph`, or any glyph). */
export type AccountProvider<P extends string = string> = {
  id: P
  label: string
  icon?: React.ReactNode
}

/** What a press answers with: the promise the control waits on, or anything else
 *  (nothing), which it does not wait on. */
type Press = unknown

export type AccountMenuSize = "md" | "lg"

/** The face at the pill's start, concentric with its round end: the pill's height
 *  less the 4 px it is inset by (`pl-1`). */
const FACE: Record<AccountMenuSize, string> = { md: "size-6", lg: "size-8" }
const FACE_PX: Record<AccountMenuSize, number> = { md: 24, lg: 32 }

/** The signed-out face draws the line every 16 px icon beside it draws (the
 *  chevron's): Lucide's 2 units of its 24-unit grid at 16 px. A Lucide line is in
 *  grid units, so it thickens with the icon: the face, larger, sets its own to land
 *  on the same screen width (Lucide's 2 would draw 2.67 px at `lg`, 1.5 drew 2). */
const LINE_PX = (2 * 16) / 24
const faceLine = (size: AccountMenuSize) => (LINE_PX * 24) / FACE_PX[size]

/** The chevron at the pill's end, concentric with its round end as the face is
 *  with the start: the end padding is half the pill's height less half the 16 px
 *  chevron (the button's own `px-4` left it 20 px of air to the face's 4). */
const END: Record<AccountMenuSize, string> = { md: "pr-2", lg: "pr-3" }
/** A 16 px icon at the start (one given in place of the face), concentric with
 *  the round start by the same arithmetic. */
const START: Record<AccountMenuSize, string> = { md: "pl-2", lg: "pl-3" }

/** A brand's mark from a 24-unit path (simple-icons' shape: `{ path }`), in the
 *  current colour, sized by whatever holds it (a button's icon slot, a menu row). */
export function ProviderGlyph({
  icon,
  className,
}: {
  icon: { path: string }
  className?: string
}) {
  return (
    <svg
      viewBox="0 0 24 24"
      aria-hidden
      fill="currentColor"
      data-slot="provider-glyph"
      className={className}
    >
      <path d={icon.path} />
    </svg>
  )
}

/** A press that waits on its callback's promise: busy from the press until it
 *  rejects, the page leaves, or the state that drew it is replaced. Restored from the
 *  back/forward cache, the page gives the press back. */
function usePress(): [boolean, (run: () => Press) => void] {
  const [busy, setBusy] = React.useState(false)
  React.useEffect(() => {
    const restored = (event: PageTransitionEvent) => {
      if (event.persisted) setBusy(false)
    }
    window.addEventListener("pageshow", restored)
    return () => window.removeEventListener("pageshow", restored)
  }, [])
  const press = React.useCallback((run: () => Press) => {
    const answer = run()
    if (!(answer instanceof Promise)) return
    setBusy(true)
    answer.catch((error: unknown) => {
      setBusy(false)
      throw error
    })
  }, [])
  return [busy, press]
}

/** The ways in, dropped from any trigger: the account control's "sign in", or a
 *  control that needs a session where the person is (a vote: `reason` heads the list
 *  with what the sign in is for). */
export function SignInMenu<P extends string>({
  providers,
  onSignIn,
  trigger,
  reason,
  material,
  scheme,
}: {
  providers: readonly AccountProvider<P>[]
  onSignIn: (provider: P) => Press
  /** The element that opens it (a Button). */
  trigger: React.ReactElement
  reason?: React.ReactNode
  material?: Material
  scheme?: Scheme
}) {
  if (providers.length === 0)
    throw new Error("SignInMenu: no providers to sign in with")
  const m = useMaterial(material)
  return (
    <DropdownMenu>
      <DropdownMenuTrigger render={trigger} />
      <DropdownMenuContent material={m} scheme={scheme}>
        {reason && (
          <>
            <DropdownMenuLabel>{reason}</DropdownMenuLabel>
            <DropdownMenuSeparator />
          </>
        )}
        {providers.map((provider) => (
          <DropdownMenuItem
            key={provider.id}
            data-provider={provider.id}
            onClick={() => onSignIn(provider.id)}
          >
            {provider.icon}
            {provider.label}
          </DropdownMenuItem>
        ))}
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

const CHEVRON = (
  <ChevronDown
    aria-hidden
    className="transition-transform group-data-[popup-open]:rotate-180"
  />
)

function SignedOut<P extends string>({
  providers,
  onSignIn,
  waiting,
  label,
  icon,
  material,
  scheme,
  size,
  className,
}: {
  providers: readonly AccountProvider<P>[]
  onSignIn: (provider: P) => Press
  waiting: boolean
  label?: React.ReactNode
  icon?: React.ReactNode
  material: Material
  scheme?: Scheme
  size: AccountMenuSize
  className?: string
}) {
  const [busy, press] = usePress()
  const only = providers.length === 1 ? providers[0] : null
  if (only)
    return (
      <Button
        data-slot="account-menu"
        data-state="signed-out"
        material={material}
        scheme={scheme}
        size={size}
        loading={waiting || busy}
        icon={only.icon}
        onClick={() => press(() => onSignIn(only.id))}
        className={className}
      >
        {label ?? `sign in with ${only.label}`}
      </Button>
    )
  return (
    <SignInMenu
      providers={providers}
      onSignIn={(id) => press(() => onSignIn(id))}
      material={material}
      scheme={scheme}
      trigger={
        <Button
          data-slot="account-menu"
          data-state="signed-out"
          material={material}
          scheme={scheme}
          size={size}
          loading={waiting || busy}
          icon={
            icon ?? (
              <CircleUserRound
                strokeWidth={faceLine(size)}
                className={cn(FACE[size], "-my-1")}
              />
            )
          }
          iconEnd={CHEVRON}
          // The face fills the round start; a given 16 px icon is concentric with
          // it as the chevron is with the end (`START` mirrors `END`).
          className={cn(
            "group",
            icon ? START[size] : "pl-1",
            END[size],
            className,
          )}
        >
          {label ?? "sign in"}
        </Button>
      }
    />
  )
}

function SignedIn({
  user,
  onSignOut,
  children,
  material,
  scheme,
  size,
  className,
}: {
  user: AccountUser
  onSignOut: () => Press
  children?: React.ReactNode
  material: Material
  scheme?: Scheme
  size: AccountMenuSize
  className?: string
}) {
  const [busy, press] = usePress()
  return (
    <DropdownMenu>
      <DropdownMenuTrigger
        render={
          <Button
            data-slot="account-menu"
            data-state="signed-in"
            aria-label={`account, ${user.name}`}
            material={material}
            scheme={scheme}
            size={size}
            loading={busy}
            iconEnd={CHEVRON}
            className={cn("group gap-2 pl-1", END[size], className)}
          >
            <span
              data-slot="account-menu-avatar"
              className={cn(
                "relative grid shrink-0 place-items-center rounded-full bg-current/12",
                FACE[size],
              )}
            >
              <span
                data-slot="account-menu-initial"
                aria-hidden
                className="text-[11px] leading-none lowercase"
              >
                {user.name.charAt(0)}
              </span>
              {user.image && (
                <Avatar
                  src={user.image}
                  alt=""
                  size="sm"
                  className="absolute inset-0 size-full"
                />
              )}
            </span>
            <span
              data-slot="account-menu-name"
              className="max-w-[12ch] truncate"
            >
              {user.name}
            </span>
          </Button>
        }
      />
      <DropdownMenuContent
        material={material}
        scheme={scheme}
        className="min-w-44"
      >
        <DropdownMenuLabel>{user.name}</DropdownMenuLabel>
        <DropdownMenuSeparator />
        {user.href && (
          <DropdownMenuLinkItem href={user.href}>
            <UserRound aria-hidden />
            profile
          </DropdownMenuLinkItem>
        )}
        {children}
        {(user.href || children) && <DropdownMenuSeparator />}
        <DropdownMenuItem onClick={() => press(onSignOut)}>
          <LogOut aria-hidden />
          sign out
        </DropdownMenuItem>
      </DropdownMenuContent>
    </DropdownMenu>
  )
}

export type AccountMenuProps<P extends string> = {
  /** Who is signed in; `null` signed out; `undefined` not known yet (the
   *  signed-out control, waiting). */
  user: AccountUser | null | undefined
  /** The ways in, at least one: one is a button, several a menu. */
  providers: readonly AccountProvider<P>[]
  onSignIn: (provider: P) => Press
  onSignOut: () => Press
  /** The signed-out control's words: default "sign in with <provider>" for one,
   *  "sign in" for several. */
  label?: React.ReactNode
  /** The signed-out control's icon, for several providers: default a face in the
   *  round slot the signed-in avatar takes, so the two states line up. Given, it is
   *  an icon beside the words like any button's: for a site whose own mark is a
   *  person, where a person here reads as a second logo (e-id's `LogIn`). */
  icon?: React.ReactNode
  /** Rows of the signed-in menu, between the profile and sign out
   *  (`DropdownMenuItem`, `DropdownMenuLinkItem`). */
  children?: React.ReactNode
  material?: Material
  /** The colour scheme it stands in (material): default the nearest provider's. */
  scheme?: Scheme
  /** Button's heights: 32 px or 40 px. */
  size?: AccountMenuSize
  /** On the trigger, whichever state it is in. */
  className?: string
}

export function AccountMenu<P extends string>({
  user,
  providers,
  onSignIn,
  onSignOut,
  label,
  icon,
  children,
  material,
  scheme,
  size = "md",
  className,
}: AccountMenuProps<P>) {
  if (providers.length === 0)
    throw new Error("AccountMenu: no providers to sign in with")
  const m = useMaterial(material)
  if (user)
    return (
      <SignedIn
        user={user}
        onSignOut={onSignOut}
        material={m}
        scheme={scheme}
        size={size}
        className={className}
      >
        {children}
      </SignedIn>
    )
  return (
    <SignedOut
      providers={providers}
      onSignIn={onSignIn}
      waiting={user === undefined}
      label={label}
      icon={icon}
      material={m}
      scheme={scheme}
      size={size}
      className={className}
    />
  )
}