SaaS & Ops

Workspace Switcher

An org/workspace switcher dropdown with per-org brand tiles, plan chips, member counts and footer actions to create a workspace or open settings.

Install

npx shadcn@latest add @paragon/workspace-switcher

Also installs: dropdown-menu

workspace-switcher.tsx

"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>
  );
}