Skip to content
goodtabs UI
BrowseBrowse components

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

PropTypeDefaultDescription
workspaces{ id: string; name: string; image?: string; plan?: string }[]Every workspace the person can switch to, in list order.
valuestringId of the current workspace. Omit to run uncontrolled.
defaultValuestringworkspaces[0].idFirst value when uncontrolled.
onValueChange(value: string) => voidCalled with the id of the chosen workspace.
onCreate() => voidAdds 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