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 four 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. Sign 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 · Settings · Help, in that order, each one only if you gave it a route.
- Your
items, in array order. - Appearance submenu, per
showThemeSubmenu. - A separator and Sign out, only when a logout handler exists.
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 sign 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 leaveprofileHrefunset.
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 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.
{ 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 four 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. Sign 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 three axes are not here. Colour palette, layout density and font size 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 "Sign 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 defaults to Vietnamese, screen-reader-only ones included, and every one is overridable key by key: labels on ShellProvider or AppShell is a Partial<ShellLabels> spread over DEFAULT_SHELL_LABELS. These nine are the ones this menu touches; the other 24 of the 33 belong to the sidebar, the switcher, the launcher and the standalone theme toggle.
| Prop | Type | Default | Description |
|---|---|---|---|
account | string | "Tài khoản" | aria-label of the trigger button. Never visible — this is the whole accessible name of the control. |
profile | string | "Hồ sơ" | Built-in row, shown only when profileHref is set. |
settings | string | "Cài đặt" | Built-in row, shown only when settingsHref is set. |
help | string | "Trợ giúp" | Built-in row, shown only when helpHref is set. |
appearance | string | "Giao diện" | Label of the theme submenu trigger. |
themeLight | string | "Sáng" | First row of the theme submenu. |
themeDark | string | "Tối" | Second row of the theme submenu. |
themeSystem | string | "Hệ thống" | Third row — follows the OS setting. |
logout | string | "Đăng xuất" | The destructive row at the bottom. |
Translate them as a set. 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 |
|---|---|---|---|
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 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, rendered in array order directly after the built-ins and directly above the appearance submenu. 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 and its separator are not rendered. |
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, Radix falls back to the initials. |
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. |
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.