Two components sharing one source directory: IdentityRow, the avatar-plus-name/subtitle strip used wherever a person, workspace, or org is presented as a row, and WorkspaceSwitcher, the org/workspace picker built entirely on top of it. Kit source: packages/ui/src/identity/.
The canon behind it. docs/design-language/00-overview.md model 2 (00-overview.md:65, identity is not status, never encode state in an identity color) and docs/design-language/08-navigation-and-shell.md §A6 (08-navigation-and-shell.md:29, identity sits at the sidebar foot, sign-out lives separately), plus docs/design-language/02-buttons-and-actions.md §A2 (02-buttons-and-actions.md:17, no colour at all marks a dead-end or exit action).
IdentityRow#
When to reach for it#
Sidebar footers, account menus, workspace pickers, team-member lists: anywhere a person or workspace needs an avatar plus a title/subtitle pair. It is purely presentational (identity-row.tsx:13-18); activation, a link, a dropdown trigger, a plain non-interactive row, is entirely the caller's choice of wrapper.
Contract#
avatar: initials/emoji for the fallback disc.image: a photo URL, wins overavatarwhen present.title(required) /subtitle: throughrenderNode, string or JSX.trailing: a free trailing slot (chevron, cog button, badge).TrailingIcon: registry-icon shorthand for the common single trailing glyph;trailingIconClassNameoverrides its default muted color.size:"sm" | "md", default"md". Two fixed scales for avatar/title/subtitle sizing (identity-row.tsx:8-11).skeleton: an avatar disc plus two text bars.- Slot classnames per
IdentityRowClassNames:""the row,avatar,title,subtitle,trailingIcon.
Real usage#
Walmart-only in shipped app code: a team-member list with skeleton rows (walmart-mvp/frontend/src/pages/Settings.tsx:468-489, avatar + title + subtitle + size="sm") and the sidebar user menu (walmart-mvp/frontend/src/App.tsx:209-215, the same three props). No speedway app usage found. Every shipped call passes only avatar, title, subtitle, and size="sm"; image, trailing, and trailingIconClassName have no confirmed shipped app call site. TrailingIcon does have a working example, but only inside the kit itself: WorkspaceSwitcher uses it for both its trigger's caret (TrailingIcon="Down") and its own row's selected checkmark (TrailingIcon={ws.id === current.id ? "Success" : undefined}), see below.
WorkspaceSwitcher#
When to reach for it#
The org/workspace picker that sits under the logo in a sidebar: the current workspace as the trigger, the rest in a floating list, a divider-led create row.
Contract#
workspaces: WorkspaceItem[]({ id, name, subtitle?, avatar?, image?, disabled?, disabledReason?, loading? }). Adisableditem is listed but not pickable: the row reads at half ink withcursor-not-allowed, anddisabledReason(a workspace still provisioning, a section not built yet) takes the subtitle's place. Aloadingitem is a skeleton row holding its place until its name arrives (owner ruling 2026-08-16;workspace-switcher.tsx:70-100).value/onChange(id, workspace): value-first, the full item rides second.onCreate: renders the divider-led creation row; omit to hide it entirely.createLabeldefault"New workspace".onViewAll: renders a "view all" row under the creation row; omit to hide it.viewAllLabeldefault"View All".skeleton: renders<IdentityRow skeleton size="sm">.placement: a floating-uiPlacement, default"bottom-start". The panel only matches the trigger's width for bottom placements (matchTriggerWidth = placement.startsWith("bottom"),workspace-switcher.tsx:40); a sidebar seat that opens sideways does not get width-matched.- Slot classnames per
WorkspaceSwitcherClassNames:""the trigger row,panelthe floating list.
Built entirely on the kit's Dropdown for positioning and IdentityRow for every row, including its own trigger.
Real usage#
Live in walmart only: App.tsx:501-514, mounted as AppShell's sidebarTop slot, workspaces mapped from the user's orgs, onCreate opens a new-org modal, no onViewAll (the view-all row is omitted).
Removed from speedway. speedway/app/root.tsx:498-511 has the entire call site commented out, with an explicit revival note: "DEPRECATED 2026-07-21 (doc-29): topbar workspace switcher hidden ... Revive: restore the WorkspaceSwitcher + its import." speedway/app/components/WorkspaceNav.tsx:12,334 carries the same dormant marker. Read this as a product decision to hide the workspace concept from users, not as evidence the component is broken or unmaintained; the commented block is preserved verbatim specifically so it can be revived.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
IdentityRow with avatar (initials) + title + subtitle + size="sm", no image, no trailing | A compact person/workspace row inside a menu or list | Settings.tsx:468-489, App.tsx:209-215 | Every shipped identity is drawn from initials, not a stored photo, so image has never been reached for |
WorkspaceSwitcher as AppShell's sidebarTop, onCreate set, onViewAll omitted | The sidebar workspace picker with a create row and no "view all" row | App.tsx:501-514 | The app has no separate all-workspaces page today, so the row is simply not offered |
Banned combinations#
Do not colour the exit. When IdentityRow is the trigger of an account menu, the sign-out row that follows it carries no colour: "No color at all marks a dead-end/exit action" (docs/design-language/02-buttons-and-actions.md:22). Walmart's live SidebarUserMenu still colours it text-error and is the recorded lapse, not the pattern.
Do not tint the avatar disc or the title by status. The identity vocabulary says WHO a thing is; the status vocabulary says how it is doing, and the canon's rule is "Never encode state in an identity color or vice versa" (docs/design-language/00-overview.md:66). A pending invite or a suspended member reads its state from a StatusPill beside the row, never from the row's own colour.
Do not hang onClick on a bare IdentityRow and call it a control. The row is purely presentational (identity-row.tsx:13-18): activation, focus, and the keyboard path belong to the wrapper, a <button>, a link, or a Dropdown trigger, which is how both shipped seats do it (walmart-mvp/frontend/src/App.tsx:209-215).
Do not give one shell two workspace pickers. WorkspaceSwitcher has one live mount point, AppShell's sidebarTop (App.tsx:501-514); a second picker in the topbar or a page is the duplicate-chrome defect app-shell.md's checklist opens with.
Before you adopt this#
Five questions to answer before reaching for IdentityRow/WorkspaceSwitcher.
- Does the shell, a parent layout, or a global provider already render this?
WorkspaceSwitcherhas one live mount point,AppShell'ssidebarTop; a second picker duplicates it. - Does this app already ship a local implementation of the same thing? Not applicable, no local IdentityRow/WorkspaceSwitcher clone is documented in either app.
- Does this app's kit pin reach the version this component or prop landed in?
WorkspaceSwitcher's disabled/loading item landed by owner ruling 2026-08-16; confirm the pin. - Does the component derive its own accessible name and keyboard path, or must the call site supply them?
IdentityRowis presentational; the wrapper<button>, link, orDropdownsupplies focus and click. - Which canon §A rules bind this surface, and which does the composition break? §A6 keeps sign-out separate from identity; identity colour never marks status.
Travels with#
Dropdown, the positioning primitiveWorkspaceSwitcheris built on.AppShell'ssidebarTopslot,WorkspaceSwitcher's one live mount point.
Snippet#
// walmart-mvp/frontend/src/App.tsx:507-514<WorkspaceSwitcher workspaces={orgs.map((o) => ({ id: o.id, name: o.name, subtitle: o.role }))} value={activeOrg.id} onChange={(id) => selectOrg(id)} onCreate={() => openModal(NEW_ORG_MODAL)} createLabel="New organization"/>