Shell
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.
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:
- Identity block. Avatar, name, then
emailandroleif you supplied them. Always present, and not selectable — it is aDropdownMenuLabel, not an item. - A separator, whenever anything below it exists — with one wrinkle: the test counts
showThemeSubmenuas an entry whenever it is notfalse, 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 thatmd:hiddenhas already taken away. - Profile · Preferences · Security · Settings · Help, in that order. The first three come from
accountUrl(3.8.0) — Profile also fromprofileHref, which wins — and the last two each need a route of their own. - Your
items, in array order — all but the destructive ones. - Appearance submenu, per
showThemeSubmenu. - 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.
UserMenuItemhas no such variant, so a longitemsarray 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
itemsand leaveprofileHrefandaccountUrlunset.
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.
Nguyễn Đức Thành
Three words → first letter of the surname plus first letter of the given name.
Trần Thị Hồng Ánh
Four words, same rule. Diacritics survive uppercasing — “Á”, not “A”.
Phan Bình
Two words, the common case.
Madonna
One word → one letter.
“”
Empty or whitespace-only → “?”, never an empty coloured box.
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.
Nguyễn Đức Thành
UserAvatar · LetterAvatar
Trần Thị Hồng Ánh
UserAvatar · LetterAvatar
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 adark: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.
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.
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
hrefthe row renders asShellNavLinkinside the menu item, which means the app’sLinkComponent(next/linkunless you swapped it), client-side navigation, a realhreffor ⌘-click, and a call toonNavigate. - No
externalflag.NavItemhas one;UserMenuItemdoes not. An off-domainhrefis handed to the router like any other. For a status page or external docs, either pointhelpHrefat it (the built-in row has the same behaviour, so check your router forwards absolute URLs) or useonSelectand open the window yourself. shortcutprints, 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 onShellProvider.variant: 'destructive'is red text, not a confirmation. It buys attention and nothing else. Anything genuinely irreversible should open a ConfirmDialog from itsonSelect.
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.
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"appliesmd:hidden. Deciding it in JavaScript would mean readingwindowduring the first render, which is a hydration mismatch; anddisplay: nonetakes 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 — unlikeuseShell()— does not throw when the provider is missing: it returns a no-op setter. WithoutThemeProviderat the root the three rows render and quietly do nothing, so passshowThemeSubmenu={false}in an app that has no theme provider. - No
mounteddance here.ThemeToggleholds 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.
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
onLogoutopen 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 letsdata-contrast="high"move the row onto--red-inkand 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.
'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.
| Prop | Type | Default | Description |
|---|---|---|---|
account | string | "Account" | aria-label of the trigger button. Never visible — this is the whole accessible name of the control. |
profile | string | "Profile" | Built-in row, shown when profileHref or accountUrl is set. |
preferences | string | "Preferences" | Built-in row, shown only when accountUrl is set — Comitor.Account's /preferences. New in 3.8.0. |
security | string | "Security" | Built-in row, shown only when accountUrl is set — Comitor.Account's /security. New in 3.8.0. |
settings | string | "Settings" | Built-in row, shown only when settingsHref is set. |
help | string | "Help" | Built-in row, shown only when helpHref is set. |
appearance | string | "Appearance" | Label of the theme submenu trigger. |
themeLight | string | "Light" | First row of the theme submenu. |
themeDark | string | "Dark" | Second row of the theme submenu. |
themeSystem | string | "System" | Third row — follows the OS setting. |
logout | string | "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
| Prop | Type | Default | Description |
|---|---|---|---|
accountUrl | string | — | 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. |
showName | boolean | false | Show 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. |
profileHref | string | — | 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. |
settingsHref | string | — | Route for the built-in “Settings” row. Same rule. |
helpHref | string | — | Route for the built-in “Help” row. Same rule. |
items | UserMenuItem[] | [] | 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”. |
showThemeSubmenu | boolean | '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 | () => void | shell.onLogout | Overrides 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. |
className | string | — | 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
| Prop | Type | Default | Description |
|---|---|---|---|
userrequired | ShellUser | — | 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. |
className | string | — | 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.
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Stable identifier. Never rendered, and deliberately not the colour seed. |
namerequired | string | — | 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. |
email | string | — | Second line of the identity block. Omitted cleanly when absent. |
avatarUrl | string | — | 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). |
role | string | — | 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
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | React key and nothing else — it never reaches the UI. Keep it unique across items. |
labelrequired | string | — | Visible text, truncated with an ellipsis inside the 15rem menu. |
icon | ShellIcon | — | A Lucide icon, or any component taking className so the shell can size it to 16px. Rows without one are not indented to match. |
href | string | — | 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. |
shortcut | string | — | 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">carryingaria-label, witharia-haspopup="menu"andaria-expandedsupplied 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 isshowName={false}. Either way, keeplabels.accounta 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
--ringoutline; menu items replace it with an--accentbackground, 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.
asChildputsrole="menuitem"onto the anchor, so it loses the “link” announcement but keeps the realhref— ⌘-click, middle-click and “copy link address” all still work. shortcutis read aloud. It is plain text in the row, notaria-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
--accentbackground and noaria-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 setshowThemeSubmenu={false}. - Sign out is not red alone. The label and the
LogOuticon 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-inkresolves 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-inkand the row clears AA. - Standalone
UserAvatarnames nobody. The photo isalt=""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 ansr-onlyfull name of its own.
Related
App Header
The bar this menu ends, and userMenuProps, the way an app reaches it.
Display Axes
ThemeToggle and the three other axes — palette, density, font size — that the appearance submenu does not cover.
Letter Avatar
The Tier 2 avatar for lists and bylines, drawing on the same eight tones and the same initials rule.
App Shell
ShellProvider, useShell() and the frame that supplies the user this menu displays.