An org/workspace switcher dropdown with per-org brand tiles, plan chips, member counts and footer actions to create a workspace or open settings.
npx shadcn@latest add @paragon/workspace-switcherAlso installs: dropdown-menu
"use client";
import * as React from "react";
import { Check, ChevronsUpDown, Plus, Settings } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/registry/paragon/ui/dropdown-menu";
import { cn } from "@/lib/utils";
export type WorkspacePlan = "free" | "pro" | "business" | "enterprise";
export interface Workspace {
id: string;
name: string;
plan?: WorkspacePlan;
/** Member count shown in the row detail. */
members?: number;
/** Optional logo accent color (any CSS color) for the avatar tile. */
accent?: string;
}
export interface WorkspaceSwitcherProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
workspaces?: Workspace[];
value?: string;
defaultValue?: string;
onChange?: (id: string) => void;
onCreate?: () => void;
onSettings?: (id: string) => void;
}
const planMeta: Record<
WorkspacePlan,
{ label: string; className: string }
> = {
free: {
label: "Free",
className: "bg-secondary text-muted-foreground",
},
pro: {
label: "Pro",
className: "bg-primary/10 text-foreground",
},
business: {
label: "Business",
className: "bg-primary text-primary-foreground",
},
enterprise: {
label: "Enterprise",
className:
"bg-foreground text-background dark:bg-foreground dark:text-background",
},
};
function initials(name: string): string {
const parts = name.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return "•";
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
return (parts[0][0] + parts[parts.length - 1][0]).toUpperCase();
}
const DEFAULTS: Workspace[] = [
{ id: "acme", name: "Acme Inc", plan: "enterprise", members: 248, accent: "oklch(0.55 0.16 250)" },
{ id: "acme-labs", name: "Acme Labs", plan: "business", members: 32, accent: "oklch(0.6 0.17 30)" },
{ id: "side", name: "Side Project", plan: "pro", members: 4, accent: "oklch(0.6 0.15 160)" },
{ id: "personal", name: "Personal", plan: "free", members: 1, accent: "oklch(0.6 0.14 300)" },
];
function PlanChip({ plan }: { plan: WorkspacePlan }) {
const meta = planMeta[plan];
return (
<span
className={cn(
"inline-flex items-center rounded-full px-1.5 py-0.5 text-[10px] font-medium",
meta.className,
)}
>
{meta.label}
</span>
);
}
function WorkspaceAvatar({
workspace,
size = "md",
}: {
workspace: Workspace;
size?: "sm" | "md";
}) {
return (
<span
aria-hidden
className={cn(
"flex shrink-0 items-center justify-center rounded-md font-semibold text-white",
size === "md" ? "size-8 text-xs" : "size-6 text-[10px]",
)}
style={{ backgroundColor: workspace.accent ?? "var(--color-primary)" }}
>
{initials(workspace.name)}
</span>
);
}
/**
* An org/workspace switcher. The trigger shows the active workspace's logo
* tile, name and plan chip; the dropdown lists all workspaces with member
* counts and per-plan chips, a check on the active one, and footer actions to
* create a workspace or open settings. Distinct from the environment switcher:
* no guarded switch — org changes are cheap — and the accent is a per-org
* brand tile rather than a status band. Controlled or uncontrolled.
*/
export function WorkspaceSwitcher({
workspaces = DEFAULTS,
value,
defaultValue,
onChange,
onCreate,
onSettings,
className,
...props
}: WorkspaceSwitcherProps) {
const [internal, setInternal] = React.useState(
defaultValue ?? workspaces[0]?.id,
);
const [open, setOpen] = React.useState(false);
const activeId = value ?? internal;
const active =
workspaces.find((w) => w.id === activeId) ?? workspaces[0];
const select = (id: string) => {
if (id !== active?.id) {
if (value === undefined) setInternal(id);
onChange?.(id);
}
setOpen(false);
};
if (!active) return null;
return (
<div
data-slot="workspace-switcher"
className={cn("w-64", className)}
{...props}
>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
className={cn(
"group pressable flex w-full items-center gap-2.5 rounded-xl bg-card p-2 text-left shadow-border outline-none",
"transition-[scale,box-shadow] duration-(--duration-fast) ease-(--ease-out) hover:shadow-border-hover",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"data-[state=open]:shadow-border-hover",
)}
>
<WorkspaceAvatar workspace={active} />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium">{active.name}</span>
{active.plan && <PlanChip plan={active.plan} />}
</span>
{typeof active.members === "number" && (
<span className="block text-xs text-muted-foreground tabular-nums">
{active.members} {active.members === 1 ? "member" : "members"}
</span>
)}
</span>
<ChevronsUpDown
className={cn(
"size-4 shrink-0 text-muted-foreground",
"transition-[color] duration-(--duration-fast) ease-(--ease-out) group-data-[state=open]:text-foreground",
)}
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align="start"
className="w-(--radix-dropdown-menu-trigger-width) min-w-64"
>
<DropdownMenuLabel>Workspaces</DropdownMenuLabel>
{workspaces.map((workspace) => (
<DropdownMenuItem
key={workspace.id}
onSelect={(event) => {
event.preventDefault();
select(workspace.id);
}}
className={cn(
"gap-2.5",
workspace.id === active.id && "bg-accent/60",
)}
>
<WorkspaceAvatar workspace={workspace} size="sm" />
<span className="min-w-0 flex-1">
<span className="flex items-center gap-1.5">
<span className="truncate text-sm">{workspace.name}</span>
{workspace.plan && <PlanChip plan={workspace.plan} />}
</span>
{typeof workspace.members === "number" && (
<span className="block text-[11px] text-muted-foreground tabular-nums">
{workspace.members}{" "}
{workspace.members === 1 ? "member" : "members"}
</span>
)}
</span>
{workspace.id === active.id && (
<Check className="size-4 shrink-0 !text-foreground" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onSelect={() => onCreate?.()}
className="gap-2.5"
>
<Plus className="size-4" aria-hidden />
Create workspace
</DropdownMenuItem>
<DropdownMenuItem
onSelect={() => onSettings?.(active.id)}
className="gap-2.5"
>
<Settings className="size-4" aria-hidden />
Workspace settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}