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. method/design-language/00-overview.md model 2 (00-overview.md:65, identity is not status, never encode state in an identity color) and method/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 method/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. icon: a registry icon in the avatar disc instead of initials, for an entity that is not a person (a team, a file, a stage).imagestill wins over it.
In a table cell#
An owner, team, file or stage in a table cell is an identity row, not plain text. size="cell" is the one-line cell shape:
- Outside a cell, a long title or subtitle truncates and shows its full text on hover when cut. The initial on the default avatar uses base ink on the primary tint (AA in light); dark keeps primary-content on solid primary.
- A small avatar, no row padding, and the title truncated to one line. The full title shows on hover only when it is cut.
subtitledoes not take a second row. It becomes the second line of a two-line tooltip under the title.tooltip={false}turns the tip off; a node astooltipreplaces the subtitle as the second line.truncate="middle"(withtruncateAt, default 28) keeps both ends of an identifier such as a file name or an id and drops the middle. The tooltip then carries the whole value.- A blank title (null or empty text) shows the table's
N/Amark, or whateveremptysays.
Use the column factory rather than writing the cell by hand: col.entity(key, row => ({ title, subtitle, avatar | icon | image, truncate }), header). The column is shaped name, so the table gives it room before the short columns, and sorts and copies by the title. col is the same object as before with entity added.
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.
Laws it enforces#
- Fit, then truncate, never clip. A cell title truncates to one line and shows its full value on hover; an identifier cuts its middle so both ends stay readable.
- Show only what has something to say. The tooltip appears when there is a second line or something is cut, and a plain short title stays quiet.
- Machine values stay exact, human text stays human. A blank owner says
N/A, and an id is cut in the middle, never recased.
Sanctioned combinations#
| Combination | Produces | Where used | Why |
|---|---|---|---|
col.entity with title, subtitle and avatar or icon | An owner, team, file or stage cell that is one line with a two-line tooltip | apps/playground/src/app/components/identity/examples/entity-cells.tsx | An entity in a cell is an identity row, so truncation, tooltip and the empty mark live in one place |
col.entity with truncate: "middle" for a file name or id | A cell that keeps the start and the extension | the same example | A cut at the end loses the part that tells two files apart |
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" (method/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" (method/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 write an entity into a table cell as plain text, or hand-roll its truncation and tooltip. Use col.entity (or IdentityRow size="cell" inside a custom render). The cell shape never shows a second row, so a long subtitle cannot grow the table row.
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#
import { WorkspaceSwitcher } from "@versable-git/ui";// 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"/>