User Menu

The last thing on the right of the header: who you are, the handful of rows an app wants to hang off that, and the way out. UserMenu reads the signed-in user from shell context; UserAvatar is the small piece of it that works anywhere.

Note what this menu is not. It does not switch workspace and it does not switch product — those are the shell’s two navigation axes, and they live in WorkspaceSwitcher and AppLauncher. What it does carry is one of the five display axes — light/dark — and only that one.

Live demo

UserMenu calls useShell(), which throws outside a provider, so the demo mounts a small ShellProvider holding one user and one logout handler. Open the menu: the identity block, three built-in rows, three app rows, and sign out in destructive red at the bottom. On a screen at md or wider you will not see Appearance — that is the default and it is explained below.

Tasks

Last action

Nothing yet — open the menu.

Nothing navigates: the demo passes its own LinkComponent. Log out is wired to onLogout on the provider.

Composition order

The order is fixed by the component; the app chooses only what exists. Six slots, top to bottom:

  1. Identity block. Avatar, name, then email and role if you supplied them. Always present, and not selectable — it is a DropdownMenuLabel, not an item.
  2. A separator, whenever anything below it exists — with one wrinkle: the test counts showThemeSubmenu as an entry whenever it is not false, so the default "mobile" keeps the rule at every width. A menu with no routes and no items therefore shows a separator above an Appearance row that md:hidden has already taken away.
  3. Profile · Preferences · Security · Settings · Help, in that order. The first three come from accountUrl (3.8.0) — Profile also from profileHref, which wins — and the last two each need a route of their own.
  4. Your items, in array order — all but the destructive ones.
  5. Appearance submenu, per showThemeSubmenu.
  6. The exit group: a separator, your variant: 'destructive' items, then Log out. Since 3.9.0 every way out sits here together — “Sign out of every product” lands right above Log out instead of among the navigation rows. The group shows when there is a logout handler or at least one destructive item.

Two consequences worth knowing before you fight the component:

  • There is no separator entry. UserMenuItem has no such variant, so a long items array renders as one unbroken run between the built-ins and Appearance. The two rules you see are structural: identity from entries, entries from the ways out. A menu that needs visual grouping is a menu that is too long — the shell’s answer is the sidebar or a settings page, not more rules.
  • Nothing can go below sign out, and nothing can go above the built-ins. If a row must lead, make it the first element of items and leave profileHref and accountUrl unset.

The avatar: initials, then colour

With avatarUrl you get the photo. Without one you get getVietnameseInitials(user.name) on a background from getAvatarToneClasses(user.name). The initials rule is first letter of the surname plus first letter of the given name — the first and last words, not the first two. Vietnamese speakers address each other by the last word of the name, so the Western “first two words” rule turns Nguyễn Đức Thành into “NĐ”, which is nobody.

NT

Nguyễn Đức Thành

Three words → first letter of the surname plus first letter of the given name.

TÁ

Trần Thị Hồng Ánh

Four words, same rule. Diacritics survive uppercasing — “Á”, not “A”.

PB

Phan Bình

Two words, the common case.

M

Madonna

One word → one letter.

?

“”

Empty or whitespace-only → “?”, never an empty coloured box.

NT

avatarUrl

A photo replaces both branches. Break the URL and the initials come back.

One person, one colour

The tone is a hash of the display name into a table of eight, and that table is the only one in the package. The Tier 3 UserAvatar in the header, the Tier 2 LetterAvatar in a member list, and getAvatarToneColors() from @comitor/ui/tokens in a PDF or an HTML email all read it — each pair below is the two components side by side, on the same name.

NTNguyễn Đức Thành

Nguyễn Đức Thành

UserAvatar · LetterAvatar

TÁTrần Thị Hồng Ánh

Trần Thị Hồng Ánh

UserAvatar · LetterAvatar

LHLê Văn Hùng

Lê Văn Hùng

UserAvatar · LetterAvatar

  • The seed is the name, never the email or the id. Every place that draws an avatar has a name in hand; not every place has an email. Seed two surfaces differently and the same colleague arrives in two colours on one screen.
  • Trimmed and lower-cased, but diacritic-sensitive. “Phan Bình” and “phan bình” are one colour; “Phan Binh” is another. Store names one canonical way, or people change colour when a form strips their tones.
  • Eight tones is decoration, not identity. Collisions are expected and fine — the colour helps you re-find a row you already know, it never encodes who someone is.
  • Both palettes, both themes. Each tone is a token pair such as bg-teal/15 text-teal-ink — a tint for the background and the ink step for the letters, with a dark: branch where the tint needs one. That is why you cannot hand-roll a ninth tone from a raw scale step.

The three built-in rows

Profile, Settings and Help are the only rows the package proposes, and each appears only when the app hands it a route. That asymmetry is on purpose: the label is a shell concern (it must translate with the rest of the menu), the route is an app concern, and a menu row that leads somewhere unspecified is worse than no row.

tsx
import { UserMenu } from '@comitor/ui/shell'

// Each built-in row appears only when you hand it a route. No href, no row —
// the package will not invent /profile and send people to a 404.
<UserMenu
  profileHref="/account"
  settingsHref="/account/settings"
  helpHref="https://docs.acme.vn"
/>

// A Comitor module points at Comitor.Account instead (3.8.0): three rows —
// Profile, Preferences, Security — in the same words and order in
// every app. profileHref still wins over the /profile it would build.
<UserMenu accountUrl="https://account.comitor.ai" />
// → /profile · /preferences · /security on that origin

// The Account app itself passes "" for relative paths.
<UserMenu accountUrl="" />

// A single-purpose app can ship a menu that is nothing but the identity block,
// the appearance submenu and sign out.
<UserMenu />

Custom items

items is where the app’s own rows go. One field decides the element that is rendered: href makes a link, onSelect alone makes a button.

tsx
import { CreditCard, Keyboard, LogOut, UserPlus } from 'lucide-react'
import { UserMenu } from '@comitor/ui/shell'
import type { UserMenuItem } from '@comitor/ui/shell'

const items: UserMenuItem[] = [
  // href → a link, rendered through the shell's LinkComponent (next/link by
  // default), so onNavigate fires and the menu behaves like every other route
  // in the app. Cmd-click and middle-click still open a new tab.
  { id: 'billing', label: 'Billing', icon: CreditCard, href: '/account/billing' },

  // onSelect → a button. Use it for anything that opens a dialog or a panel.
  { id: 'invite', label: 'Invite teammates', icon: UserPlus, onSelect: () => openInvite() },

  // Both at once is legal: the link navigates AND onSelect fires — the hook
  // for analytics on a row that is genuinely a link.
  { id: 'plan', label: 'Upgrade plan', href: '/billing/plans', onSelect: () => track('upgrade') },

  // shortcut is a printed hint, nothing more. Register the key yourself.
  { id: 'shortcuts', label: 'Keyboard shortcuts', icon: Keyboard, shortcut: 'Ctrl /', onSelect: openHelp },

  // variant: 'destructive' paints the row with --destructive-ink AND, since
  // 3.9.0, moves it into the exit group: below the separator, above sign out.
  { id: 'leave', label: 'Leave workspace', icon: LogOut, variant: 'destructive', onSelect: confirmLeave },
]

<UserMenu items={items} />
  • A link stays a link. With href the row renders as ShellNavLink inside the menu item, which means the app’s LinkComponent (next/link unless you swapped it), client-side navigation, a real href for ⌘-click, and a call to onNavigate.
  • No external flag. NavItem has one; UserMenuItem does not. An off-domain href is handed to the router like any other. For a status page or external docs, either point helpHref at it (the built-in row has the same behaviour, so check your router forwards absolute URLs) or use onSelect and open the window yourself.
  • shortcut prints, it does not bind. The string is a hint on the right of the row; the key handler is yours to register. Only the shell’s own four global keys (⌘K, ⌘⇧K, ⌘B, Alt+1…9) are wired, and they live on ShellProvider.
  • variant: 'destructive' is red text, not a confirmation. It buys attention and nothing else. Anything genuinely irreversible should open a ConfirmDialog from its onSelect.

The appearance submenu

The menu carries one — and only one — of the five display axes: light / dark / system, written to the same next-themes store the standalone ThemeToggle writes to. The two are not identical controls: the header renders the toggle in its default variant="icon", one button that flips light ⇄ dark and never reaches “system”, while this submenu always offers all three — only variant="menu" matches it row for row. They are still two doors into one room, which is why the default is "mobile": AppHeader renders its ThemeToggle as hidden md:flex and the submenu trigger as md:hidden, so every width has exactly one entry point. Move one breakpoint without the other and you get a band of widths with two, or with none. The preview below forces the submenu on at every width.

This one is not a sandbox. These docs are themselves wrapped in the package’s ThemeProvider, so picking Light or Dark below really does change the whole site, and stores your choice — which is exactly what a working appearance submenu is supposed to do.

Tasks

Last action

Nothing yet — open the menu.

Nothing navigates: the demo passes its own LinkComponent. Log out is wired to onLogout on the provider.

  • Hidden by CSS, not by a condition. "mobile" applies md:hidden. Deciding it in JavaScript would mean reading window during the first render, which is a hydration mismatch; and display: none takes the row out of the accessibility tree too, so a desktop screen-reader user is not offered a phantom row.
  • It needs the provider that the toggle needs. The submenu calls useTheme() from next-themes, which — unlike useShell() — does not throw when the provider is missing: it returns a no-op setter. Without ThemeProvider at the root the three rows render and quietly do nothing, so pass showThemeSubmenu={false} in an app that has no theme provider.
  • No mounted dance here. ThemeToggle holds an empty box until it mounts, because it is painted into the header on the first render and the real theme is only knowable on the client. The submenu needs nothing of the kind: menu content is mounted when the menu opens, which is always after hydration.
  • The other four axes are not here. Contrast palette, layout density, font size and theme palette have their own providers and controls, documented on Display Axes. If your app exposes them, a settings page is the honest home — four submenus in a user menu is a preferences dialog wearing a disguise.

Signing out

The bottom row runs onLogout — the prop if you passed one, otherwise the handler on shell context. It is the app’s function: the package clears no session, calls no endpoint and knows no auth library.

tsx
import { ShellProvider, UserMenu } from '@comitor/ui/shell'

// Usual place: once on the provider, for every consumer of the shell.
<ShellProvider user={user} onLogout={() => signOut()}>
  <UserMenu />
</ShellProvider>

// Per-instance override — the prop wins over the context handler.
<UserMenu onLogout={() => signOutOfThisDeviceOnly()} />

// Neither one set? The row and the separator above it are not rendered at all,
// rather than rendering a dead "Log out".
<ShellProvider user={user}>
  <UserMenu />
</ShellProvider>
  • No handler, no row. The separator above it goes as well, so a read-only or SSO-managed surface ends cleanly at the last real entry instead of showing a button that cannot work.
  • It fires on the first click. There is no confirmation step. If unsaved work is at stake, let onLogout open a ConfirmDialog and sign out from there.
  • Red comes from the ink role. The row is --destructive-ink, not --destructive: the latter is the background role, the fill under white text on a delete button. In the default palette the ink token still points at it, so the two are one hex and the row measures 4.38:1 light, 4.06:1 dark. Writing the role token anyway is what lets data-contrast="high" move the row onto --red-ink and clear AA.

Inside AppShell

Apps rarely mount this component by hand. AppHeader puts it at the end of the right-hand cluster — app launcher, notifications, theme toggle, a hairline divider, then the menu — and forwards everything on this page through userMenuProps. The user itself comes from the provider, not from the header: with user null, both the divider and the menu are simply absent.

app/(app)/layout.tsx
'use client'

import type { ReactNode } from 'react'
import { AppShell } from '@comitor/ui/shell'
import { signOut } from './auth'
import { NAV } from './nav'
import { USER_MENU_ITEMS } from './user-menu-items'

export function AppLayout({ children }: { children: ReactNode }) {
  return (
    <AppShell
      nav={NAV}
      user={{ id: 'u1', name: 'Nguyễn Đức Thành', email: '[email protected]', role: 'Quản trị viên' }}
      onLogout={signOut}
      headerProps={{
        // Everything here is forwarded straight to <UserMenu />.
        userMenuProps: {
          showName: true,
          profileHref: '/account',
          settingsHref: '/account/settings',
          items: USER_MENU_ITEMS,
        },
        // showUserMenu={false} drops the menu entirely — a kiosk or a public
        // read-only view. With no user in context it disappears on its own.
      }}
    >
      {children}
    </AppShell>
  )
}

The nine label keys it reads

Every string in the shell, screen-reader-only ones included, follows ComitorLocaleProvider — English when there is none, Vietnamese under locale="vi" — and every one is overridable key by key: labels on ShellProvider or AppShell is a Partial<ShellLabels> merged over the provider’s table. These nine are the ones this menu touches; the other 25 of the 34 belong to the sidebar, the switcher, the launcher, the skip link and the standalone theme toggle. The defaults below are the English table; both languages side by side are on App Shell.

PropTypeDefaultDescription
accountstring"Account"aria-label of the trigger button. Never visible — this is the whole accessible name of the control.
profilestring"Profile"Built-in row, shown when profileHref or accountUrl is set.
preferencesstring"Preferences"Built-in row, shown only when accountUrl is set — Comitor.Account's /preferences. New in 3.8.0.
securitystring"Security"Built-in row, shown only when accountUrl is set — Comitor.Account's /security. New in 3.8.0.
settingsstring"Settings"Built-in row, shown only when settingsHref is set.
helpstring"Help"Built-in row, shown only when helpHref is set.
appearancestring"Appearance"Label of the theme submenu trigger.
themeLightstring"Light"First row of the theme submenu.
themeDarkstring"Dark"Second row of the theme submenu.
themeSystemstring"System"Third row — follows the OS setting.
logoutstring"Log out"The destructive row at the bottom.

The provider switches them as a set; keep your overrides in the same language as the rest. An English account over Vietnamese visible rows gives a voice-control user a name they cannot see and a menu they cannot say — the same failure WCAG 2.5.3 is about, arriving from the other direction.

UserMenu props

PropTypeDefaultDescription
accountUrlstring—New in 3.8.0. The origin of Comitor.Account, with no trailing slash. With a value the menu builds three rows at its top — Profile (/profile), Preferences (/preferences), Security (/security) — so every app shows the shared identity pages in the same words and order. The Account app passes "" for relative paths. profileHref wins over the /profile it would build.
showNamebooleanfalseShow the name and role next to the avatar in the trigger, plus a chevron. Leave it off on narrow screens — the trigger then costs one 32px avatar.
profileHrefstring—Route for the built-in “Profile” row. Omit it (and accountUrl) and the row does not exist; the package never invents a route of its own.
settingsHrefstring—Route for the built-in “Settings” row. Same rule.
helpHrefstring—Route for the built-in “Help” row. Same rule.
itemsUserMenuItem[][]App-specific rows, in array order. Ordinary rows sit directly after the built-ins and above the appearance submenu; destructive rows (since 3.9.0) go to the exit group, below the separator and right above sign out. There is no separator entry — see “Composition order”.
showThemeSubmenuboolean | 'mobile''mobile'“mobile” shows the appearance submenu below md only, because AppHeader carries a ThemeToggle from md up. true always shows it (use it when the header toggle is off). false never does. The “mobile” branch hides with md:hidden rather than a conditional render — a width-dependent render would need window at first paint and break hydration, and display:none removes the row from the accessibility tree too.
onLogout() => voidshell.onLogoutOverrides the context handler for this instance. With neither prop nor context handler, the sign-out row is not rendered — and the separator above it stays only when there are destructive items to sit under it.
align'start' | 'center' | 'end''end'Alignment of the popup against the trigger, forwarded to DropdownMenuContent. The menu is 15rem wide.
classNamestring—Extra classes on the trigger button, not on the popup. Use align (or a class on the content through your own composition) for placement.

UserAvatar props

PropTypeDefaultDescription
userrequiredShellUser—Passed as a prop, not read from context — this is the one piece of the user menu that renders outside a ShellProvider. name drives both the initials and the colour; avatarUrl, when present, replaces both.
classNamestring—Merged onto the Avatar root, so this is where size lives (size-8 by default). It cannot recolour the fallback: the tone classes sit on the fallback element and always come from the name. There is no toneClassName escape hatch here — LetterAvatar has one.

ShellUser

The user contract of the whole shell — passed once to ShellProvider or AppShell and read from context by everything that needs it, including MobileMenu.

PropTypeDefaultDescription
idrequiredstring—Stable identifier. Never rendered, and deliberately not the colour seed.
namerequiredstring—Full display name. Shown in the trigger (with showName) and in the identity block, and it is the seed for both the initials and the avatar tone.
emailstring—Second line of the identity block. Omitted cleanly when absent.
avatarUrlstring—Photo. Rendered through AvatarImage with alt="" — the name is already in the block next to it, so an alt would be read twice. If it fails to load, or the next user has none, the initials take over (the latter since 3.17.3).
rolestring—Role in the current workspace, as a ready-to-display string — the shell maps no codes. Appears under the name in the trigger and as the third line of the identity block.

UserMenuItem

PropTypeDefaultDescription
idrequiredstring—React key and nothing else — it never reaches the UI. Keep it unique across items.
labelrequiredstring—Visible text, truncated with an ellipsis inside the 15rem menu.
iconShellIcon—A Lucide icon, or any component taking className so the shell can size it to 16px. Rows without one are not indented to match.
hrefstring—Renders the row as a link through ShellNavLink → the shell’s LinkComponent, which also fires onNavigate. There is no external flag here (NavItem has one): an off-domain URL still goes through the app router, so use onSelect and window.open for those.
onSelect() => void—Renders the row as a button. Allowed alongside href, in which case it fires on click as well as the navigation — the place for analytics.
shortcutstring—Right-aligned hint, e.g. “Ctrl /”. Display only: nothing binds the key. It is plain text inside the row, so a screen reader reads it as part of the item.
variant'default' | 'destructive''default'destructive paints text and icon with --destructive-ink (the ink role, not the background role --destructive), which is the token the high-contrast palette repoints at --red-ink. Since 3.9.0 it also moves the row into the exit group, below the separator and right above sign out — so give it only to a way out; anything else destructive belongs elsewhere.

Accessibility

  • The trigger is named by labels.account, always. It is a real <button type="button"> carrying aria-label, with aria-haspopup="menu" and aria-expanded supplied by Radix. The label overrides the initials inside it, so nobody hears “N T”.
  • One caveat with showName. The visible text then reads “Nguyễn Đức Thành”, while the accessible name stays “Account”. The visible string is a value rather than a label, so the functional name is defensible — but if an audit reads it as WCAG 2.5.3 (Label in Name), the lever is showName={false}. Either way, keep labels.account a short word someone would actually say out loud.
  • Menu semantics come from Radix. The popup is role="menu" with roving focus: arrow keys move, typeahead jumps, Esc closes and returns focus to the trigger, and focus is trapped while it is open. The appearance submenu opens with → or Enter and closes with ←.
  • Rows suppress the page-wide focus ring on purpose. The base layer gives every focusable element a 2px --ring outline; menu items replace it with an --accent background, the convention for a list where focus moves under the arrow keys. The trigger keeps the standard ring.
  • The identity block is not focusable. Rendered as a DropdownMenuLabel, it is read when the menu opens but never lands under an arrow key — arrowing down goes straight to the first row that does something.
  • A link row is announced as a menu item. asChild puts role="menuitem" onto the anchor, so it loses the “link” announcement but keeps the real href — ⌘-click, middle-click and “copy link address” all still work.
  • shortcut is read aloud. It is plain text in the row, not aria-hidden, so “Keyboard shortcuts Ctrl /” is the announced name. Keep the hint short and pronounceable; a wall of symbols is worse heard than seen.
  • The selected theme is marked visually only. The current row in the appearance submenu gets an --accent background and no aria-checked, so a screen reader hears three equal choices. If your product needs the state announced, use a control of your own on a settings page and set showThemeSubmenu={false}.
  • Sign out is not red alone. The label and the LogOut icon carry the meaning; the colour only raises it — which matters here, because the colour itself is short of the bar. In the default palette --destructive-ink resolves to --destructive (#D64545): 4.38:1 on the popover in light, 4.06:1 in dark. The remedy is the second palette — data-contrast="high" repoints the token at --red-ink and the row clears AA.
  • Standalone UserAvatar names nobody. The photo is alt="" and the fallback is bare initials — correct inside a labelled trigger, silent or spelt out anywhere else. Outside the menu, either give the surrounding element the name, or use LetterAvatar, which carries an sr-only full name of its own.

Related