Shell
Workspace Switcher
Axis 1 of the shell: the control that changes which organisation’s data you are looking at. It takes no data props — it reads workspaces out of useShell() and reports a choice back through one callback.
Two axes, perpendicular
A Comitor user belongs to many workspaces, and each workspace has many products enabled. Switching workspace does not switch product, and switching product does not switch workspace — which is exactly why the two cannot collapse into one flat menu.
Axis 1
WorkspaceSwitcher
Changes the data context — Acme Corp ▸ Umbrella Health. This page.
Axis 2
AppLauncher
Changes the product inside one workspace — Tasks ▸ Chat ▸ CRM. Documented separately.
Not to be confused with the four display axes — light/dark, colour palette, layout density, font size — which are independent of both and live at Display Axes.
Live demo
Open the switcher, pick a workspace, try the four footer rows. Every one of them reports what it called into the panel on the right — that is the entire API surface of this component, plus a Popover.
Three workspaces, so no search box. The current row keeps its check mark; the others keep the space the check would take, which is what holds the plan badges in one column.
Last callback
—The search box appears at eight
SEARCH_THRESHOLD, a constant internal to the component rather than an export, is 8. Nothing switches it on — the switcher counts workspaces.length and decides for itself. The count is of the whole list, never the filtered one, so the box does not vanish from under the cursor once a query has narrowed things down.
Nine workspaces. Type dong and “Đông Á Media” matches; phu my finds “Phú Mỹ Industrial”. Slugs match too, so sao-viet works without a single tone mark being typed.
Last callback
—- The filter is
normalizeVion both sides — tones stripped, lower-cased, trimmed — overnameandslug. Vietnamese users type without diacritics constantly; a switcher that only matched the exact string would be useless to them. The same function is exported from@comitor/ui, so app-side filtering can be made to agree with it. planandroleare not searched — they are shown, not indexed.- The list is capped at
max-h-64and scrolls; the search box stays pinned above it. Rows sit in agap-1column so their rounded hover backgrounds cannot fuse into one slab. - When the query matches nothing, the list is replaced by
labels.workspaceEmpty— not by an empty box.
Collapsed — the icon rail
With collapsed, the trigger drops to a 36px square holding nothing but the avatar, and gains a right-side Tooltip carrying the workspace name. The panel is unchanged — same width, same list, same footer.
Hover the avatar for the tooltip, click it for the panel. Note the avatar here is 24px inside a 36px button — padding, not fill. The expanded trigger uses 32px instead, so that the avatar stands as tall as the two lines of text beside it.
Last callback
—The avatar is seeded on name
No logoUrl, and the avatar falls back to initials on a deterministic tone — getAvatarToneClasses(workspace.name). The seed is the name, not the slug and not the id, and that choice is load-bearing: the switcher is never the only place a workspace is drawn. A member list, a share sheet, a breadcrumb — each of them reaches for LetterAvatar, which seeds on the name it is given. Seed one of them on the slug and the same organisation comes out in two different colours in the same product.
On the right, the same three workspaces drawn by LetterAvatar instead. Open the switcher and compare: same seed, same hash, same tone out of the same table of eight.
Last callback
—- Initials are Vietnamese-style —
getVietnameseInitialstakes the first letter of the first word and the first letter of the last, not of the first two words, so “Phú Mỹ Industrial” becomes “PI” and not “PM”. - The tile is
rounded-md, never a circle. Round avatars mean people in this system; square ones mean workspaces and apps. - One tone table serves the whole package. Outside the DOM — PDF, email, canvas —
getAvatarToneColors(name, mode)from@comitor/ui/tokensreturns the same tone as hex, andgetAvatarToneIndex(name)gives you the slot number if you need to key something else off it. The tone table itself is not exported: reach for one of those three functions rather than rebuilding the mapping app-side, which is the shortest path to two colours for one workspace.
Footer actions
Below a separator, up to four rows: create a workspace, open its settings, invite members, sign out. Each row exists only because its callback was passed to the shell — there is no row to disable and no dead “Invite members” for a viewer who cannot invite anyone. Pass none of the four and the separator goes too.
'use client'
import { useRouter } from 'next/navigation'
import { ShellProvider, WorkspaceSwitcher } from '@comitor/ui/shell'
import { signOut } from '@/lib/auth'
import { EN, WORKSPACES } from './shell-config'
import { openCreateDialog, openInviteDialog } from './workspace-dialogs'
/**
* Each footer row exists only because its callback was passed. Hand the shell
* nothing and the separator and the whole block disappear — there is no row to
* disable, no dead "Invite members" for a viewer who cannot invite anyone.
*/
export function WorkspaceStrip({ canCreate, isAdmin }: { canCreate: boolean; isAdmin: boolean }) {
const router = useRouter()
return (
<ShellProvider
workspaces={WORKSPACES}
labels={EN}
onCreateWorkspace={canCreate ? openCreateDialog : undefined}
onWorkspaceSettings={isAdmin ? (id) => router.push(`/w/${id}/settings`) : undefined}
onInviteMembers={isAdmin ? (id) => openInviteDialog(id) : undefined}
onLogout={signOut}
>
{/* Or keep the callbacks for the rest of the shell and drop the block here. */}
<WorkspaceSwitcher showActions={false} />
</ShellProvider>
)
}- Every row closes the panel before running its callback. An action that opens a dialog would otherwise open it underneath a popover that is still on screen.
onWorkspaceSettingsandonInviteMembersreceive the current workspace id — the panel acts on the workspace you are in, not on a row you happened to hover.- Sign-out is the same
onLogoutthat UserMenu andMobileMenucall. One handler for the whole shell. showActions={false}hides the block without touching the callbacks, for a header trigger that should stay a pure switcher.
One workspace, or none
With a single workspace there is nothing to switch to, so the list section is replaced by a plain identity block: avatar, name, and plan ?? slug underneath. The footer actions still make sense there — creating a second workspace is exactly what such a user might do — so they stay. With an empty workspaces array the component renders null outright, and Sidebar drops the whole strip rather than leaving a 56px band of nothing.
One workspace. The trigger still opens — it is where the actions live — but there is no list, no search, and no check mark to place.
Last callback
—Where it sits
Two placements, and AppShell picks between them from one prop. In sidebar-first, the default, the switcher is the top band of the sidebar — the demos above. In header-first it moves into the header as the logo slot, and the sidebar’s own band is turned off in the same breath, so the two can never both be on screen. That is also the one place in the package that passes className to this component.
'use client'
import type { ReactNode } from 'react'
import { AppShell } from '@comitor/ui/shell'
import { EN, NAV, USER, WORKSPACES } from './shell-config'
/**
* layout="header-first" moves the switcher out of the sidebar and into the
* header, where AppShell mounts it as the logo slot with a fixed width:
*
* <WorkspaceSwitcher className="w-56 shrink-0" />
*
* and turns Sidebar's own switcher strip off, so the two never appear at once.
* `className` lands on the trigger button, not on the panel — the panel is
* w-80 either way.
*/
export default function AppLayout({ children }: { children: ReactNode }) {
return (
<AppShell layout="header-first" workspaces={WORKSPACES} nav={NAV} user={USER} labels={EN}>
{children}
</AppShell>
)
}Why Popover and not DropdownMenu
The plain menus of the shell — UserMenu, row actions in a table — are a DropdownMenu. This one is not, and the reason is recorded in the source: Radix’s menu implements typeahead. Printable keystrokes inside an open menu are captured to jump to the item that starts with them. That behaviour is correct for a menu and fatal here — the search box would never receive a single character it was typed.
Building on Popover gives the panel a free-form layout with a working text input, at a cost that is paid honestly rather than papered over: no roving focus, no arrow-key navigation, no aria-activedescendant. So the rows are plain buttons in a role="group" and are reached with Tab, and the component makes no ARIA promise it does not keep — see Accessibility below. AppLauncher sits on a Popover for the same reason and pays the cost differently: its tiles are a grid, so it writes its own ←/→/↑/↓ handling rather than leaving keyboard users with Tab.
The panel is w-80 (320px), not the 288px it started at: the current row is the tightest one in the list, because it alone carries both a plan badge and a check mark, and at 288px its secondary line was clipped by about 4px. It was not widened by 4px — names and role strings are app data, so the width has margin rather than fitting one sample set exactly.
WorkspaceSwitcher props
| Prop | Type | Default | Description |
|---|---|---|---|
collapsed | boolean | false | Icon-rail rendering: a 36px square button holding just the avatar, wrapped in a right-side Tooltip carrying the workspace name. Sidebar passes its own sidebarCollapsed down, so only a hand-built frame sets this. |
showActions | boolean | true | Show the footer block (create, settings, invite, sign out). Off, the panel is the workspace list alone. The block is dropped anyway when none of the four callbacks was passed to the shell. |
align | 'start' | 'center' | 'end' | 'start' | Alignment of the panel against the trigger, forwarded to PopoverContent. The default opens flush with the left edge of the sidebar; center and end are for a trigger sitting elsewhere in a header. |
className | string | — | Merged onto the trigger button in both renderings — not onto the panel. AppShell uses it in the header-first layout (w-56 shrink-0) to give the trigger a fixed width in the header row. |
The Workspace type
The data contract for axis 1. Three required fields; everything else is optional and simply omits the thing it would have drawn.
| Prop | Type | Default | Description |
|---|---|---|---|
idrequired | string | — | Identity. Matched against ShellProvider currentWorkspaceId, and handed to onWorkspaceChange, onWorkspaceSettings and onInviteMembers. |
namerequired | string | — | Visible name, source of the initials, and the seed of the avatar colour. Not the slug — see below. |
slugrequired | string | — | URL / subdomain identifier. Searched alongside the name, and shown as the second line of the single-workspace panel when there is no plan. The shell displays it and never navigates to it. |
logoUrl | string | — | Workspace logo. Present, it fills the avatar; absent, the tinted initials take over. Both are rounded-md, never a circle — a circle would read as a person. |
plan | string | — | Pre-formatted plan name ("Free", "Pro", "Business"). Second line of the expanded trigger, and a secondary Badge on every row of the list. Display only: the shell infers no permission from it. |
role | string | — | The user’s role in this workspace, already a display string — the shell maps no codes. First half of the secondary line in the list. |
memberCount | number | — | Second half of that line, rendered as `labels.members(memberCount)` and joined to the role with " · ". Only one of the two is needed; the line is dropped when both are missing. |
ShellProvider props that drive it
The switcher has no data props of its own — these go on ShellProvider, or on AppShell, which forwards every one of them.
| Prop | Type | Default | Description |
|---|---|---|---|
workspaces | Workspace[] | [] | Everything the user belongs to. Empty, the switcher renders null (and Sidebar drops the strip rather than leaving an empty band). Exactly one, and the list section is replaced by a plain identity block. |
currentWorkspaceId | string | workspaces[0] | The workspace in context. Keep it in app state and update it from onWorkspaceChange — the switcher holds no selection of its own. |
onWorkspaceChange | (workspaceId: string) => void | — | Fires on picking a different workspace; picking the current one closes the panel and calls nothing. Routing, subdomain swap and cache reset are the app’s job. |
onCreateWorkspace | () => void | — | Renders the first footer row (Plus icon). Omit it and the row does not exist. |
onWorkspaceSettings | (workspaceId: string) => void | — | Second footer row (Settings icon), called with the current workspace id. |
onInviteMembers | (workspaceId: string) => void | — | Third footer row (UserPlus icon), also called with the current workspace id. |
onLogout | () => void | — | Last footer row (LogOut icon), tinted with --destructive-ink. Shared with UserMenu and MobileMenu — one sign-out handler for the whole shell. |
labels | Partial<ShellLabels> | DEFAULT_SHELL_LABELS | Display strings, spread over the Vietnamese defaults key by key. The nine keys this component reads are listed below. |
Labels it reads
Nine of the 33 keys in ShellLabels. Defaults are Vietnamese and are spread under your overrides, so a partial object is enough — but override all nine for an English app, or the panel ends up bilingual.
| Prop | Type | Default | Description |
|---|---|---|---|
workspaceSwitcherLabel | string | 'Đổi không gian làm việc' | aria-label of the trigger button, in both the expanded and the collapsed rendering. |
workspace | string | 'Không gian làm việc' | Accessible name of the role="group" wrapping the list of workspaces. |
workspaceSearchPlaceholder | string | 'Tìm không gian làm việc…' | Placeholder AND aria-label of the search input — one key doing both jobs, so the visible text and the accessible name can never drift apart. |
workspaceEmpty | string | 'Không tìm thấy không gian làm việc' | Shown in place of the list when the query matches nothing. |
members | (count: number) => string | (count) => `${count} thành viên` | The whole member-count phrase on the secondary line of each row, not just the unit — a function because it interpolates the number, and word order around it changes by language. |
createWorkspace | string | 'Tạo không gian làm việc' | Visible text of the create row. |
workspaceSettings | string | 'Cài đặt không gian làm việc' | Visible text of the settings row. |
inviteMembers | string | 'Mời thành viên' | Visible text of the invite row. |
logout | string | 'Đăng xuất' | Visible text of the sign-out row, the one tinted as destructive. |
Accessibility
- It is deliberately not a listbox.
role="listbox"androle="option"promise a screen reader a widget driven by ↑/↓/Home/End with roving tabindex andaria-activedescendant. What actually happens is Tab through ordinary buttons in a popover. The component declares what it does: plain buttons inside arole="group"named bylabels.workspace, witharia-current="true"on the workspace you are in. - The current row is not marked by colour alone. Its background tint —
bg-accent/60— measures 1.05:1 against the panel in light and 1.06:1 in dark, which is nothing at all for the one job it has. So the check mark stays, and it is always in the layout, merelyinvisibleon the other rows: that both keeps the badge column aligned and satisfies WCAG 1.4.1. It is the same trickComboboxuses for its option rows. - The check mark is ink, not app accent.
text-app-accentwould put the default gold at 1.86:1 on the white panel, under the 3:1 that WCAG 1.4.11 asks of a non-text indicator.--popover-foregroundmeasures 18.9:1 light and 16.6:1 dark — and it is a role token, so an app that repaints the panel gets a check mark that follows. - The trigger chevron carries information — it says this button opens a panel — so it answers to 3:1, and it must clear that on every background the trigger can stand on. In the light palette
--muted-foregroundmeasures 3.67:1 on--sidebar, 3.50:1 on--sidebar-accentunder hover and 3.83:1 on--background— that last one because header-first moves this trigger onto the page background. Dark clears it more comfortably. An alpha step of the sidebar foreground was rejected for landing on ~3:1 in light while reading quite differently in dark, which is how translucent steps usually break exactly one theme. - Read the ratios in the component source with a date on them. The comments inside
workspace-switcher.tsxquote a darker generation of the ink tokens. 0.3.0 pulled the default palette back to the approved comitor-ds colours and 0.9.1 ships that palette, so the token names are unchanged but their values moved, and the package now lists its known sub-threshold spots at the top ofstyles.css. Two of them land in this panel: the 11px plan and role lines are text and so answer to 4.5:1, but--muted-foregroundgives 3.83:1 on the panel — 3.63:1 on the current row’s tint, 3.50:1 on a hovered one — in light while clearing the bar in dark; and the sign-out row on--destructive-inkgives 4.38:1 light and 4.06:1 dark. The remedy is not a patch here — it is the second palette,data-contrast="high", which the user turns on themselves. See Display Axes. - The search field signals focus twice. The border switches to
--primary-inkand a 2px--ring/50halo appears — which matters, because in the default light palette brand gold sits at 1.86:1 against the white field, so the border alone would be a state change nobody can see. The ring does the work there; in dark the brand step is bright (11.9:1) and the border reads on its own. Both are role tokens: point--primary-inkat a darker brand step and this field follows without a patch. - Sign-out is text, so it takes the ink token.
--destructiveis a background token — the fill under white text on a delete button — and using it for a label is the classic three-role mix-up.--destructive-inkis the step declared for red-on-page, and the hover state is a/10tint rather than a solid fill, so the label never has to survive on a saturated red. - Opening the panel puts the caret in the search box when there is one — the component prevents Radix’s default of focusing the panel frame. Below eight workspaces there is no box and the default applies. Escape closes the panel and returns focus to the trigger; the popover is non-modal, so Tab walks the rows in DOM order.
- The trigger’s accessible name is
labels.workspaceSwitcherLabel, anaria-labelthat replaces the visible workspace name for assistive technology. A screen reader hears “Switch workspace, button”, and learns which workspace is current from thearia-currentrow inside the panel. In the collapsed rail the name is also carried by the tooltip. Worth knowing if your users drive the product by voice: the phrase that activates this button is the label, not the name on screen. - Translating the labels keeps the sidebar rule. A key that is both visible text and an accessible name must move as one — here
workspaceSearchPlaceholderis a single key filling both roles, so it cannot drift; the sibling case to watch iscollapseSidebar/collapseSidebarShorton Sidebar, where the short string has to stay a substring of the long one (WCAG 2.5.3).
Related
App Launcher
Axis 2 — the other half of the pair. Same idea, perpendicular direction: products inside one workspace.
Sidebar
The frame that mounts this component, hands it its collapsed state, and drops the strip when there are no workspaces.
App Shell
Where workspaces and the callbacks are passed in real apps, and where the two layouts decide the switcher’s placement.
Letter Avatar
The tone table behind the workspace avatar, and the one function every other surface must use to agree with it.