sign in with a provider, or the person's face and name opening their menu
npx shadcn add @ag/account-menuAlready installed? Preview an update
npx shadcn add @ag/account-menu --dry-runYour 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 },nullsigned out,undefinednot known yet),providers,onSignIn(provider)andonSignOut. One provider is one button with its mark and words (sign in with github); several are onesign inopening a menu of them. Signed in, the face and name open a menu: the profile link, your rows aschildren, sign out. A press returning a promise shows a spinner, width kept, until the page leaves,userchanges or the promise rejects.SignInMenudrops the ways in from any trigger (a vote's, with areason);ProviderGlyphdraws a simple-icons mark.mdorlg, 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.
also called login, sign in, sign out, logout, user menu, user button, auth, account
shelf controls
the demo above, verbatim
"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
"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}
/>
)
}