# Workspace Switcher

> A sidebar-header workspace dropdown with plan badges, a create row and search for long lists.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/workspace-switcher
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/workspace-switcher.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

```bash
npm install lucide-react
```

## Usage

```tsx title="components/workspace-switcher-demo.tsx"
"use client"

import * as React from "react"

import { WorkspaceSwitcher, type Workspace } from "@/components/workspace-switcher"

const teams: Workspace[] = [
  { id: "acme", name: "Acme Inc", plan: "Enterprise" },
  { id: "side", name: "Side Projects", plan: "Free" },
  { id: "studio", name: "Moonlight Studio", plan: "Pro" },
]

const agency: Workspace[] = [
  { id: "a1", name: "Brightside Media", plan: "Pro" },
  { id: "a2", name: "Cobalt Labs", plan: "Pro" },
  { id: "a3", name: "Driftwood", plan: "Free" },
  { id: "a4", name: "Evergreen Co", plan: "Enterprise" },
  { id: "a5", name: "Foxglove", plan: "Free" },
  { id: "a6", name: "Granite Works", plan: "Pro" },
  { id: "a7", name: "Harbor & Pine", plan: "Free" },
]

export default function WorkspaceSwitcherDemo() {
  const [team, setTeam] = React.useState("acme")
  const [list, setList] = React.useState(agency)
  const [client, setClient] = React.useState("a2")

  return (
    <div className="grid min-h-[28rem] w-full max-w-2xl grid-cols-[minmax(0,1fr)] content-start gap-6 sm:grid-cols-2">
      <section className="grid min-w-0 content-start gap-3 max-sm:pb-60">
        <p className="text-sm font-medium text-muted-foreground">Sidebar header, 3 workspaces</p>
        <div className="rounded-2xl border bg-card p-2">
          <WorkspaceSwitcher defaultOpen workspaces={teams} value={team} onValueChange={setTeam} />
        </div>
      </section>
      <section className="grid min-w-0 content-start gap-3">
        <p className="text-sm font-medium text-muted-foreground">{list.length} workspaces, searchable</p>
        <div className="rounded-2xl border bg-card p-2">
          <WorkspaceSwitcher
            workspaces={list}
            value={client}
            onValueChange={setClient}
            onCreate={() => {
              const id = `new-${list.length + 1}`
              setList((items) => [...items, { id, name: `New workspace ${items.length - 6}`, plan: "Free" }])
              setClient(id)
            }}
          />
        </div>
      </section>
    </div>
  )
}
```

## Source

```tsx title="components/workspace-switcher.tsx"
"use client"

import * as React from "react"
import { Check, ChevronsUpDown, Plus, Search } from "lucide-react"

import { cn } from "@/lib/utils"

export interface Workspace {
  id: string
  name: string
  /** Plan label, e.g. "Free", "Pro" or "Enterprise" */
  plan?: string
  /** Up to two characters shown in the tile; derived from the name by default */
  initials?: string
}

export interface WorkspaceSwitcherProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  workspaces: Workspace[]
  /** Selected workspace id (controlled) */
  value?: string
  defaultValue?: string
  onValueChange?: (id: string) => void
  onCreate?: () => void
  /** A search field is shown when there are more workspaces than this */
  searchThreshold?: number
  /** Render the list open on mount (without moving focus) */
  defaultOpen?: boolean
}

const tileColors = ["bg-violet-600", "bg-emerald-700", "bg-amber-700", "bg-sky-700", "bg-rose-700", "bg-indigo-600"]

function initialsOf(name: string) {
  return name
    .split(/\s+/)
    .filter(Boolean)
    .slice(0, 2)
    .map((part) => part[0]?.toUpperCase())
    .join("")
}

function Tile({ workspace, index, className }: { workspace: Workspace; index: number; className?: string }) {
  return (
    <span
      aria-hidden
      className={cn(
        "flex size-8 shrink-0 items-center justify-center rounded-lg text-xs font-semibold text-white",
        tileColors[index % tileColors.length],
        className
      )}
    >
      {workspace.initials ?? initialsOf(workspace.name)}
    </span>
  )
}

function PlanBadge({ plan }: { plan: string }) {
  return (
    <span
      className={cn(
        "shrink-0 rounded-full px-1.5 text-[11px] leading-4.5 font-medium",
        plan.toLowerCase() === "free" ? "bg-muted text-muted-foreground" : "bg-primary/10 text-primary"
      )}
    >
      {plan}
    </span>
  )
}

export function WorkspaceSwitcher({
  workspaces,
  value,
  defaultValue,
  onValueChange,
  onCreate,
  searchThreshold = 5,
  defaultOpen = false,
  className,
  ...props
}: WorkspaceSwitcherProps) {
  const [inner, setInner] = React.useState(defaultValue ?? workspaces[0]?.id)
  const [open, setOpen] = React.useState<"closed" | "focus" | "quiet">(defaultOpen ? "quiet" : "closed")
  const [query, setQuery] = React.useState("")
  const [active, setActive] = React.useState(0)
  const root = React.useRef<HTMLDivElement>(null)
  const button = React.useRef<HTMLButtonElement>(null)
  const focusTarget = React.useRef<HTMLElement>(null)
  const typed = React.useRef({ text: "", at: 0 })
  const id = React.useId()

  const currentId = value ?? inner
  const currentIndex = Math.max(
    0,
    workspaces.findIndex((workspace) => workspace.id === currentId)
  )
  const current = workspaces[currentIndex]
  const searchable = workspaces.length > searchThreshold
  const isOpen = open !== "closed"

  const visible = React.useMemo(() => {
    const needle = query.trim().toLowerCase()
    return workspaces
      .map((workspace, index) => ({ workspace, index }))
      .filter(({ workspace }) => !needle || workspace.name.toLowerCase().includes(needle))
  }, [workspaces, query])
  // Rows are the visible workspaces followed by the "Create workspace" row
  const createIndex = visible.length

  React.useEffect(() => {
    if (open === "focus") focusTarget.current?.focus()
  }, [open])

  React.useEffect(() => {
    if (!isOpen) return
    const onPointerDown = (event: PointerEvent) => {
      if (!root.current?.contains(event.target as Node)) setOpen("closed")
    }
    const onDocumentKeyDown = (event: KeyboardEvent) => {
      if (event.key === "Escape" && !root.current?.contains(document.activeElement)) setOpen("closed")
    }
    document.addEventListener("pointerdown", onPointerDown)
    document.addEventListener("keydown", onDocumentKeyDown)
    return () => {
      document.removeEventListener("pointerdown", onPointerDown)
      document.removeEventListener("keydown", onDocumentKeyDown)
    }
  }, [isOpen])

  React.useEffect(() => {
    if (!isOpen) return
    // Keep the active row visible inside the scrolling list without scrolling the page
    const row = document.getElementById(`${id}-row-${active}`)
    const box = row?.parentElement
    if (!row || !box) return
    if (row.offsetTop < box.scrollTop) box.scrollTop = row.offsetTop
    else if (row.offsetTop + row.offsetHeight > box.scrollTop + box.clientHeight)
      box.scrollTop = row.offsetTop + row.offsetHeight - box.clientHeight
  }, [isOpen, active, id])

  const openList = () => {
    setQuery("")
    setActive(currentIndex)
    setOpen("focus")
  }

  const close = () => {
    setOpen("closed")
    button.current?.focus()
  }

  const choose = (index: number) => {
    if (index === createIndex) {
      onCreate?.()
    } else {
      const picked = visible[index]?.workspace
      if (picked) {
        if (value === undefined) setInner(picked.id)
        onValueChange?.(picked.id)
      }
    }
    close()
  }

  const onKeyDown = (event: React.KeyboardEvent<HTMLElement>) => {
    switch (event.key) {
      case "ArrowDown":
        setActive((i) => Math.min(i + 1, createIndex))
        break
      case "ArrowUp":
        setActive((i) => Math.max(i - 1, 0))
        break
      case "Home":
        if (searchable) return
        setActive(0)
        break
      case "End":
        if (searchable) return
        setActive(createIndex)
        break
      case "Enter":
        choose(active)
        break
      case " ":
        // In the search field Space is a normal character
        if (searchable) return
        choose(active)
        break
      case "Escape":
        close()
        break
      case "Tab":
        setOpen("closed")
        return
      default: {
        if (searchable || event.key.length !== 1 || !/\S/.test(event.key) || event.metaKey || event.ctrlKey || event.altKey) return
        const now = event.timeStamp
        typed.current = { text: (now - typed.current.at < 600 ? typed.current.text : "") + event.key.toLowerCase(), at: now }
        const match = visible.findIndex(({ workspace }) => workspace.name.toLowerCase().startsWith(typed.current.text))
        if (match >= 0) setActive(match)
        return
      }
    }
    event.preventDefault()
  }

  const listboxId = `${id}-list`
  const activeDescendant = `${id}-row-${active}`

  return (
    <div ref={root} className={cn("relative w-full", className)} {...props}>
      <button
        ref={button}
        type="button"
        aria-label={`${current ? (current.initials ?? initialsOf(current.name)) : ""} ${current?.name}${current?.plan ? ` ${current.plan} plan` : ""}. Switch workspace`}
        aria-haspopup="listbox"
        aria-expanded={isOpen}
        aria-controls={isOpen ? listboxId : undefined}
        onClick={() => (isOpen ? setOpen("closed") : openList())}
        onKeyDown={(event) => {
          if (event.key === "ArrowDown" || event.key === "ArrowUp") {
            event.preventDefault()
            openList()
          }
        }}
        className="flex w-full items-center gap-3 rounded-xl border bg-background p-2 text-left shadow-xs transition-colors outline-none hover:bg-accent focus-visible:ring-4 focus-visible:ring-ring/40 aria-expanded:bg-accent"
      >
        {current && <Tile workspace={current} index={currentIndex} className="size-9" />}{" "}
        <span className="min-w-0 flex-1">
          <span className="block truncate text-sm font-medium">{current?.name}</span>{" "}
          {current?.plan && <span className="block truncate text-xs text-muted-foreground">{current.plan} plan</span>}
        </span>
        <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" aria-hidden />
      </button>

      {isOpen && (
        <div
          className="absolute top-full right-0 left-0 z-30 mt-2 min-w-60 max-w-[calc(100vw-2rem)] rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg animate-in fade-in-0 zoom-in-95 slide-in-from-top-1 origin-top motion-reduce:animate-none"
        >
          {searchable && (
            <div className="relative p-1">
              <Search className="pointer-events-none absolute top-1/2 left-3.5 size-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
              <input
                ref={(el) => {
                  focusTarget.current = el
                }}
                type="text"
                role="combobox"
                aria-label="Search workspaces"
                aria-expanded
                aria-controls={listboxId}
                aria-activedescendant={activeDescendant}
                aria-autocomplete="list"
                placeholder="Search workspaces…"
                value={query}
                onChange={(event) => {
                  setQuery(event.target.value)
                  setActive(0)
                }}
                onKeyDown={onKeyDown}
                className="h-9 w-full min-w-0 rounded-lg border bg-background pr-3 pl-8 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-4 focus-visible:ring-ring/40"
              />
              <p role="status" className="sr-only">
                {visible.length === 0 ? "No workspaces found" : `${visible.length} workspaces`}
              </p>
            </div>
          )}
          <div
            ref={(el) => {
              if (!searchable) focusTarget.current = el
            }}
            id={listboxId}
            role="listbox"
            aria-label="Workspaces"
            tabIndex={searchable ? undefined : -1}
            aria-activedescendant={searchable ? undefined : activeDescendant}
            onKeyDown={searchable ? undefined : onKeyDown}
            className="max-h-64 overflow-y-auto outline-none"
          >
            {visible.map(({ workspace, index }, row) => {
              const selected = workspace.id === current?.id
              return (
                <div
                  key={workspace.id}
                  id={`${id}-row-${row}`}
                  role="option"
                  aria-selected={selected}
                  data-active={row === active || undefined}
                  onPointerMove={() => setActive(row)}
                  onClick={() => choose(row)}
                  className="flex h-12 cursor-default items-center gap-3 rounded-lg px-2 text-sm select-none data-[active]:bg-accent data-[active]:text-accent-foreground"
                >
                  <Tile workspace={workspace} index={index} />
                  <span className="min-w-0 flex-1 truncate font-medium">{workspace.name}</span>
                  {workspace.plan && <PlanBadge plan={workspace.plan} />}
                  <Check className={cn("size-4 shrink-0", !selected && "invisible")} aria-hidden />
                </div>
              )
            })}
            {visible.length === 0 && (
              <p aria-hidden className="px-3 py-4 text-center text-sm text-muted-foreground">
                No workspaces found
              </p>
            )}
            <div aria-hidden className="-mx-1 my-1 h-px bg-border" />
            <div
              id={`${id}-row-${createIndex}`}
              role="option"
              aria-selected={false}
              data-active={active === createIndex || undefined}
              onPointerMove={() => setActive(createIndex)}
              onClick={() => choose(createIndex)}
              className="flex h-11 cursor-default items-center gap-3 rounded-lg px-2 text-sm font-medium select-none data-[active]:bg-accent data-[active]:text-accent-foreground"
            >
              <span aria-hidden className="flex size-8 shrink-0 items-center justify-center rounded-lg border border-dashed text-muted-foreground">
                <Plus className="size-4" />
              </span>
              Create workspace
            </div>
          </div>
        </div>
      )}
    </div>
  )
}
```
