Workspace switcher
The workspace you are working in, and a menu of the others to switch to.
The list is a radio group, not plain menu entries: picking a workspace is choosing one of a set, so each row reports aria-checked and the active one carries the tick for free. Base UI keeps a radio menu open after a pick, which suits a filter but not a destination, so the switcher closes itself. The trigger is a full-width outline button — drop it into a sidebar header and it fills the rail — named after the active workspace; the initials mark beside it is decorative, so a reader hears "Northwind Analytics" rather than "N L Northwind Analytics" — and the plan beside the name is kept apart from it, so the trigger and every row read "Northwind Analytics, Team" rather than running the two together. An activeId matching nothing leaves the trigger reading "Select workspace" rather than emptying the sidebar. The trigger carries data-slot="workspace-switcher" and the popup data-slot="workspace-switcher-content". WorkspaceMark is exported for lists that show the same mark elsewhere.
Install
npx shadcn@latest add @vibra/workspace-switcherNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import * as React from "react"
import { WorkspaceSwitcher, type Workspace } from "@/components/ui/workspace-switcher"
const WORKSPACES: Workspace[] = [
{ id: "northwind", name: "Northwind", plan: "Enterprise" },
{ id: "lumen", name: "Lumen Studio", plan: "Pro" },
{ id: "playground", name: "Ada's Playground", plan: "Free" },
]
export default function WorkspaceSwitcherDemo() {
const [activeId, setActiveId] = React.useState("northwind")
const [creating, setCreating] = React.useState(false)
const active = WORKSPACES.find((workspace) => workspace.id === activeId)
return (
<div className="flex w-full max-w-64 flex-col gap-3">
{/* The width a sidebar rail gives it. */}
<div className="rounded-lg border p-2">
<WorkspaceSwitcher
workspaces={WORKSPACES}
activeId={activeId}
onSelect={(id) => {
setActiveId(id)
setCreating(false)
}}
onCreate={() => setCreating(true)}
/>
</div>
<p className="text-sm text-muted-foreground">
{creating ? "Creating a workspace…" : `Working in ${active?.name}.`}
</p>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| workspaces | Workspace[] | — | Everything the reader can switch to, in the order to show them. |
| activeId | string | — | The workspace being worked in. |
| onSelect | (id: string) => void | — | Called with the id picked; the menu closes itself. |
| onCreate | () => void | — | Adds a "Create workspace" entry with a Plus under the list. |
| className | string | — | Classes for the trigger button, which is the root; the remaining button props are spread onto it too. |
| size | "sm" | "default" | "default" | sm drops the trigger to h-7 and the mark to size-5. |
| Workspace.id | string | — | Unique; what onSelect reports. |
| Workspace.name | string | — | The workspace; its initials fill the mark. |
| Workspace.plan | string | — | A short tier shown quietly beside the name. |
| Workspace.logo | React.ReactNode | — | Replaces the initials in the mark; sized to 3.5. |
Dependencies
Source
"use client"
import * as React from "react"
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { getInitials } from "@/lib/format"
export type Workspace = {
id: string
name: string
/** A short plan or tier, shown quietly beside the name. */
plan?: string
/** Replaces the initials in the mark; sized to 3.5. */
logo?: React.ReactNode
}
// The trigger button is the root: className and the rest of the props land on
// it. className is re-declared as a plain string, and size is the item's
// density axis, passed straight through to the button.
export type WorkspaceSwitcherProps = Omit<
React.ComponentProps<typeof Button>,
"className" | "size" | "variant" | "children" | "render" | "onSelect"
> & {
workspaces: Workspace[]
activeId: string
onSelect: (id: string) => void
/** Adds a "Create workspace" entry under the list. */
onCreate?: () => void
className?: string
size?: "sm" | "default"
}
export type WorkspaceMarkProps = {
workspace: Workspace
size?: "sm" | "default"
}
/** The square mark that stands in for a workspace — its logo, or its initials. */
function WorkspaceMark({ workspace, size = "default" }: WorkspaceMarkProps) {
return (
<span
data-slot="workspace-switcher-mark"
// Decorative: the name sits right beside it, and initials read as
// letter noise ("A C M E") to a screen reader.
aria-hidden="true"
className={cn(
"flex shrink-0 items-center justify-center rounded-md bg-primary font-semibold text-primary-foreground [&_svg]:size-3.5",
size === "sm" ? "size-5 text-avatar" : "size-6 text-2xs"
)}
>
{workspace.logo ?? getInitials(workspace.name)}
</span>
)
}
/** The workspace you are working in, and a menu of the others to switch to. */
function WorkspaceSwitcher({
workspaces,
activeId,
onSelect,
onCreate,
className,
size = "default",
...props
}: WorkspaceSwitcherProps) {
const active = workspaces.find((workspace) => workspace.id === activeId)
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
type="button"
variant="outline"
size={size}
data-slot="workspace-switcher"
data-size={size}
className={cn("w-full justify-between", className)}
{...props}
/>
}
>
{active ? (
<span className="flex min-w-0 items-center gap-2">
<WorkspaceMark workspace={active} size={size} />
<span className="truncate font-medium">{active.name}</span>
{/* The plan is a short token and the name is not, so the name is
the one that gives way when the rail is narrow. The comma is
for the button's name: "Northwind Analytics, Pro", not "Northwind AnalyticsPro". */}
{active.plan ? (
<span className="shrink-0 text-xs font-normal text-muted-foreground">
<span className="sr-only">,</span> {active.plan}
</span>
) : null}
</span>
) : (
// An id that matches nothing is a caller bug, but emptying the
// sidebar over it helps nobody: the switcher still switches.
<span className="truncate text-muted-foreground">Select workspace</span>
)}
<ChevronsUpDownIcon className="shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
{/* COUPLED TO registry/vibra/ui/item.tsx: see the same note in
user-menu.tsx — this rename takes [data-slot=dropdown-menu-content]
away from the popup, and the switcher's rows are its own. */}
<DropdownMenuContent
data-slot="workspace-switcher-content"
align="start"
className="min-w-56"
>
<DropdownMenuRadioGroup
value={activeId}
onValueChange={(value) => onSelect(String(value))}
>
{/* Inside the group, not above it: Base UI's GroupLabel reads the
group context to name it, and throws without one. */}
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuRadioItem
key={workspace.id}
value={workspace.id}
// Base UI keeps a radio menu open by default, which suits a
// filter; picking the workspace you are moving to is a
// destination, so the menu gets out of the way.
closeOnClick
data-slot="workspace-switcher-item"
// The menu item repaints every descendant on focus
// (focus:**:text-accent-foreground), which lands white-on-white
// on the mark's own bg-primary tile in dark. This selector is one
// specificity step higher, so the tile keeps its own foreground
// on the row that opens focused — the active workspace's.
className="gap-2 focus:[&_[data-slot=workspace-switcher-mark]]:text-primary-foreground"
>
<WorkspaceMark workspace={workspace} size="sm" />
<span className="truncate">{workspace.name}</span>
{workspace.plan ? (
<span className="ms-auto shrink-0 text-xs text-muted-foreground">
<span className="sr-only">,</span> {workspace.plan}
</span>
) : null}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{onCreate ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem data-slot="workspace-switcher-create" onClick={onCreate}>
<PlusIcon className="text-muted-foreground" />
Create workspace
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
)
}
export { WorkspaceMark, WorkspaceSwitcher }