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.

Tasks

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:

  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 · Settings · Help, in that order, each one only if you gave it a route.
  4. Your items, in array order.
  5. Appearance submenu, per showThemeSubmenu.
  6. 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. 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 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 items and leave profileHref 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.

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

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 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.
  { 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 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.

Tasks

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" 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 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.

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 "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 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 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.

PropTypeDefaultDescription
accountstring"Tài khoản"aria-label of the trigger button. Never visible — this is the whole accessible name of the control.
profilestring"Hồ sơ"Built-in row, shown only when profileHref is set.
settingsstring"Cài đặt"Built-in row, shown only when settingsHref is set.
helpstring"Trợ giúp"Built-in row, shown only when helpHref is set.
appearancestring"Giao diện"Label of the theme submenu trigger.
themeLightstring"Sáng"First row of the theme submenu.
themeDarkstring"Tối"Second row of the theme submenu.
themeSystemstring"Hệ thống"Third row — follows the OS setting.
logoutstring"Đă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

PropTypeDefaultDescription
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.
profileHrefstringRoute for the built-in “Profile” row. Omit it and the row does not exist; the package never invents a route of its own.
settingsHrefstringRoute for the built-in “Settings” row. Same rule.
helpHrefstringRoute for the built-in “Help” row. Same rule.
itemsUserMenuItem[][]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”.
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 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.
classNamestringExtra 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
userrequiredShellUserPassed 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.
classNamestringMerged 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
idrequiredstringStable identifier. Never rendered, and deliberately not the colour seed.
namerequiredstringFull 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.
emailstringSecond line of the identity block. Omitted cleanly when absent.
avatarUrlstringPhoto. 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.
rolestringRole 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
idrequiredstringReact key and nothing else — it never reaches the UI. Keep it unique across items.
labelrequiredstringVisible text, truncated with an ellipsis inside the 15rem menu.
iconShellIconA Lucide icon, or any component taking className so the shell can size it to 16px. Rows without one are not indented to match.
hrefstringRenders 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() => voidRenders the row as a button. Allowed alongside href, in which case it fires on click as well as the navigation — the place for analytics.
shortcutstringRight-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"> 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