Workspace switcher
A menu for moving between workspaces, with avatars, a check and a create item.
npx shadcn@latest add https://ui.goodtabs.co/r/workspace-switcher.json
With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/workspace-switcher
Three workspaces and a Create workspace item, at a fixed width.
In a sidebar header
The ghost look at full width above a short nav list.
With plans
Each item shows its plan as mono meta on the right.
Usage
import { WorkspaceSwitcher } from "@/components/ui/workspace-switcher"
<WorkspaceSwitcher
workspaces={[{ id: "goodtabs", name: "goodtabs", plan: "Pro" }]}
value={workspace}
onValueChange={setWorkspace}
onCreate={() => router.push("/new")}
/>Props
| Prop | Type | Default | Description |
|---|---|---|---|
| workspaces | { id: string; name: string; image?: string; plan?: string }[] | Every workspace the person can switch to, in list order. | |
| value | string | Id of the current workspace. Omit to run uncontrolled. | |
| defaultValue | string | workspaces[0].id | First value when uncontrolled. |
| onValueChange | (value: string) => void | Called with the id of the chosen workspace. | |
| onCreate | () => void | Adds a Create workspace item after a separator. | |
| size | "sm" | "default" | "default" | Trigger height: 28px or 32px. |
| variant | "outline" | "ghost" | "outline" | Look of the trigger. |
Source
workspace-switcher/workspace-switcher.tsx
"use client";
import * as React from "react";
import { ChevronsUpDownIcon, PlusIcon } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type Workspace = {
id: string;
name: string;
image?: string;
/** Mono meta on the right of the item, for example "Pro". */
plan?: string;
};
type WorkspaceSwitcherProps = Omit<
React.ComponentProps<typeof Button>,
"value" | "defaultValue" | "size" | "variant" | "children"
> & {
/** Every workspace the person can switch to. */
workspaces: Workspace[];
/** Id of the current workspace. */
value?: string;
/** First value when uncontrolled. */
defaultValue?: string;
/** Called with the id of the chosen workspace. */
onValueChange?: (value: string) => void;
/** Adds a Create workspace item after a separator. */
onCreate?: () => void;
/** Trigger height: 28px or 32px. */
size?: "sm" | "default";
/** Look of the trigger. */
variant?: "outline" | "ghost";
};
function initials(name: string) {
return name
.trim()
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0]?.toUpperCase() ?? "")
.join("");
}
function WorkspaceAvatar({ workspace }: { workspace: Workspace }) {
return (
<Avatar className="size-5 rounded-sm after:rounded-sm">
{workspace.image ? <AvatarImage src={workspace.image} alt="" className="rounded-sm" /> : null}
<AvatarFallback className="rounded-sm text-xs leading-none font-medium text-foreground/70">
{initials(workspace.name)}
</AvatarFallback>
</Avatar>
);
}
const itemClass = "group/workspace-item h-8 rounded-sm focus:bg-muted focus:text-foreground";
/**
* A menu for moving between workspaces. The trigger shows the current one; the list checks it.
* Fits a sidebar header at full width or a toolbar at its own width.
*/
export function WorkspaceSwitcher({
workspaces,
value,
defaultValue,
onValueChange,
onCreate,
size = "default",
variant = "outline",
className,
...props
}: WorkspaceSwitcherProps) {
const [internal, setInternal] = React.useState(defaultValue ?? workspaces[0]?.id ?? "");
const current = value === undefined ? internal : value;
const selected = workspaces.find((w) => w.id === current) ?? workspaces[0];
const choose = (next: string) => {
if (value === undefined) setInternal(next);
onValueChange?.(next);
};
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant={variant}
size={size}
data-slot="workspace-switcher"
className={cn("max-w-full justify-start pl-1.5", size === "sm" ? "pr-1.5" : "pr-2", className)}
{...props}
>
{selected ? <WorkspaceAvatar workspace={selected} /> : null}
<span className="truncate">{selected?.name ?? "Choose a workspace"}</span>
<ChevronsUpDownIcon className="ml-auto text-muted-foreground" aria-hidden="true" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="min-w-56 rounded-md border shadow-md ring-0">
<DropdownMenuRadioGroup value={current} onValueChange={choose}>
{workspaces.map((workspace) => (
<DropdownMenuRadioItem key={workspace.id} value={workspace.id} className={itemClass}>
<WorkspaceAvatar workspace={workspace} />
<span className="truncate">{workspace.name}</span>
{workspace.plan ? (
<span className="ml-auto shrink-0 font-mono text-xs text-muted-foreground group-focus/workspace-item:text-foreground/70">
{workspace.plan}
</span>
) : null}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
{onCreate ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onCreate} className={itemClass}>
<span className="flex size-5 shrink-0 items-center justify-center">
<PlusIcon className="size-4" aria-hidden="true" />
</span>
Create workspace
</DropdownMenuItem>
</>
) : null}
</DropdownMenuContent>
</DropdownMenu>
);
}
For AI agents
Hand your agent this page, the markdown at /components/workspace-switcher/prompt.md, or the Copy AI prompt button above.
When to use
- At the top of an app sidebar, where the current team or account should always be visible
- In a header toolbar of a multi-tenant app
- When people belong to a handful of workspaces and create new ones now and then
When not to use
- For a single workspace; show the name as text
- For dozens of workspaces; build a searchable combobox on Command
- For a user account menu with sign out and settings; that is a different menu
Agent notes
- A demo or parent that passes onValueChange or onCreate must be a client component
- Controlled with value and onValueChange, or uncontrolled with defaultValue; it falls back to the first workspace
- Pass className w-full and variant ghost for a sidebar header; the name truncates and the chevrons stay right
- Avatars are rounded-sm squares with initials; pass image for a logo