Tenant Switcher

Workspace, organization or project switcher for multi-tenant apps. Logo or initials, plan or role caption, grouped and searchable list, and a create action. Compound API.

Example

Installation

npx shadcn@latest add https://hirael.com/r/tenant-switcher.json

API

<TenantSwitcher />

PropTypeDefault
valuestring
defaultValuestring
onValueChange((value: string | undefined) => void)
tenantsTenant[][]
openboolean
defaultOpenbooleanfalse
onOpenChange((open: boolean) => void)
disabledboolean
childrenReact.ReactNode

<TenantSwitcherTrigger />

+ native element props
PropTypeDefault
placeholderstring"Select workspace"
childrenReact.ReactNode

<TenantSwitcherContent />

+ native element props
PropTypeDefault
searchablebooleantrue
searchPlaceholderstring"Find workspace…"
emptyMessagestring"No workspaces found."
footer

Pinned below the scrolling list, e.g. a <TenantSwitcherCreate>.

React.ReactNode

<TenantSwitcherItem />

+ native element props
PropTypeDefault
tenant*Tenant
childrenReact.ReactNode

<TenantSwitcherCreate />

+ native element props

No props of its own — forwards everything to the underlying element.

Component source

"use client";

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

import { cn } from "@/lib/utils";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/hirael/ui/popover";
import {
  Command,
  CommandEmpty,
  CommandGroup,
  CommandInput,
  CommandItem,
  CommandList,
  CommandSeparator,
} from "@/registry/hirael/ui/command";

export type Tenant = {
  value: string;
  label: string;
  /** Logo image URL. Falls back to the label's initials. */
  image?: string;
  /** Secondary line under the name, e.g. a plan or role. */
  caption?: string;
  /** Group heading, e.g. "Personal" vs "Teams". */
  group?: string;
  disabled?: boolean;
};

function useControllableState<T>(
  controlled: T | undefined,
  defaultValue: T,
  onChange?: (value: T) => void,
) {
  const [uncontrolled, setUncontrolled] = React.useState(defaultValue);
  const value = controlled === undefined ? uncontrolled : controlled;
  const setValue = React.useCallback(
    (next: T) => {
      if (controlled === undefined) setUncontrolled(next);
      onChange?.(next);
    },
    [controlled, onChange],
  );
  return [value, setValue] as const;
}

type TenantSwitcherContextValue = {
  value: string | undefined;
  setValue: (value: string | undefined) => void;
  active: Tenant | undefined;
  tenants: Tenant[];
  open: boolean;
  setOpen: (open: boolean) => void;
  disabled?: boolean;
};

const TenantSwitcherContext =
  React.createContext<TenantSwitcherContextValue | null>(null);

function useTenantSwitcher() {
  const context = React.useContext(TenantSwitcherContext);
  if (!context) {
    throw new Error(
      "TenantSwitcher parts must be used within <TenantSwitcher>",
    );
  }
  return context;
}

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

function TenantLogo({
  tenant,
  className,
}: {
  tenant?: Tenant;
  className?: string;
}) {
  return (
    <span
      data-slot="tenant-switcher-logo"
      className={cn(
        "flex size-7 shrink-0 items-center justify-center overflow-hidden rounded-sm bg-muted text-[11px] font-medium text-foreground",
        className,
      )}
    >
      {tenant?.image ? (
        // eslint-disable-next-line @next/next/no-img-element
        <img src={tenant.image} alt="" className="size-full object-cover" />
      ) : tenant ? (
        initials(tenant.label)
      ) : null}
    </span>
  );
}

export type TenantSwitcherProps = {
  value?: string;
  defaultValue?: string;
  onValueChange?: (value: string | undefined) => void;
  tenants?: Tenant[];
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  disabled?: boolean;
  children?: React.ReactNode;
};

function TenantSwitcher({
  value: valueProp,
  defaultValue,
  onValueChange,
  tenants = [],
  open: openProp,
  defaultOpen = false,
  onOpenChange,
  disabled,
  children,
}: TenantSwitcherProps) {
  const [value, setValue] = useControllableState(
    valueProp,
    defaultValue,
    onValueChange,
  );
  const [open, setOpen] = useControllableState(
    openProp,
    defaultOpen,
    onOpenChange,
  );
  const active = tenants.find((tenant) => tenant.value === value);

  const context = React.useMemo<TenantSwitcherContextValue>(
    () => ({ value, setValue, active, tenants, open, setOpen, disabled }),
    [value, setValue, active, tenants, open, setOpen, disabled],
  );

  return (
    <TenantSwitcherContext.Provider value={context}>
      <Popover open={open} onOpenChange={setOpen}>
        {children}
      </Popover>
    </TenantSwitcherContext.Provider>
  );
}

function TenantSwitcherTrigger({
  placeholder = "Select workspace",
  className,
  children,
  ...props
}: Omit<React.ComponentProps<"button">, "children"> & {
  placeholder?: string;
  children?: React.ReactNode;
}) {
  const { active, open, disabled } = useTenantSwitcher();

  return (
    <PopoverTrigger asChild>
      <button
        type="button"
        role="combobox"
        aria-expanded={open}
        aria-haspopup="listbox"
        disabled={disabled}
        data-slot="tenant-switcher-trigger"
        data-state={open ? "open" : "closed"}
        className={cn(
          "group flex h-12 w-full items-center gap-2.5 rounded-md border border-input bg-transparent px-2.5 text-start text-sm outline-none transition-colors",
          "hover:border-ring/60 focus-visible:border-ring data-[state=open]:border-ring",
          "disabled:cursor-not-allowed disabled:opacity-50",
          className,
        )}
        {...props}
      >
        {children ?? (
          <>
            <TenantLogo tenant={active} />
            <span className="flex min-w-0 flex-1 flex-col">
              <span
                className={cn(
                  "truncate font-medium leading-tight",
                  !active && "text-muted-foreground",
                )}
              >
                {active ? active.label : placeholder}
              </span>
              {active?.caption && (
                <span className="truncate text-xs leading-tight text-muted-foreground">
                  {active.caption}
                </span>
              )}
            </span>
            <ChevronsUpDown className="size-4 shrink-0 text-muted-foreground" />
          </>
        )}
      </button>
    </PopoverTrigger>
  );
}

function TenantSwitcherContent({
  className,
  searchable = true,
  searchPlaceholder = "Find workspace…",
  emptyMessage = "No workspaces found.",
  footer,
  children,
  ...props
}: React.ComponentProps<typeof PopoverContent> & {
  searchable?: boolean;
  searchPlaceholder?: string;
  emptyMessage?: string;
  /** Pinned below the scrolling list, e.g. a <TenantSwitcherCreate>. */
  footer?: React.ReactNode;
}) {
  const { tenants } = useTenantSwitcher();
  const groups = useGroupedTenants(tenants);

  return (
    <PopoverContent
      align="start"
      sideOffset={6}
      data-slot="tenant-switcher-content"
      className={cn(
        "w-(--radix-popover-trigger-width) min-w-[15rem] p-0",
        className,
      )}
      onOpenAutoFocus={(event) => event.preventDefault()}
      {...props}
    >
      <Command loop>
        {searchable && <CommandInput placeholder={searchPlaceholder} />}
        <CommandList>
          <CommandEmpty>{emptyMessage}</CommandEmpty>
          {children ??
            groups.map(([group, items]) => (
              <CommandGroup key={group ?? "__ungrouped"} heading={group}>
                {items.map((tenant) => (
                  <TenantSwitcherItem key={tenant.value} tenant={tenant} />
                ))}
              </CommandGroup>
            ))}
        </CommandList>
        {footer && (
          <>
            <CommandSeparator />
            <div className="p-1">{footer}</div>
          </>
        )}
      </Command>
    </PopoverContent>
  );
}

function TenantSwitcherItem({
  tenant,
  children,
  className,
  ...props
}: Omit<
  React.ComponentProps<typeof CommandItem>,
  "value" | "onSelect" | "children"
> & {
  tenant: Tenant;
  children?: React.ReactNode;
}) {
  const { value, setValue, setOpen } = useTenantSwitcher();
  const selected = value === tenant.value;

  return (
    <CommandItem
      value={`${tenant.label} ${tenant.value}`}
      disabled={tenant.disabled}
      onSelect={() => {
        setValue(tenant.value);
        setOpen(false);
      }}
      data-slot="tenant-switcher-item"
      className={cn("gap-2.5", className)}
      {...props}
    >
      <TenantLogo tenant={tenant} className="size-6 text-[10px]" />
      <span className="flex min-w-0 flex-1 flex-col">
        <span className="truncate leading-tight">
          {children ?? tenant.label}
        </span>
        {tenant.caption && (
          <span className="truncate text-xs leading-tight text-muted-foreground">
            {tenant.caption}
          </span>
        )}
      </span>
      {selected && <Check className="size-4 text-foreground" strokeWidth={3} />}
    </CommandItem>
  );
}

function TenantSwitcherCreate({
  className,
  children = "Create workspace",
  ...props
}: React.ComponentProps<"button">) {
  return (
    <button
      type="button"
      data-slot="tenant-switcher-create"
      className={cn(
        "flex w-full items-center gap-2.5 rounded-sm px-2 py-1.5 text-start text-sm outline-hidden transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
        className,
      )}
      {...props}
    >
      <span className="flex size-6 shrink-0 items-center justify-center rounded-sm border border-dashed border-input text-muted-foreground">
        <Plus className="size-4" />
      </span>
      <span className="truncate">{children}</span>
    </button>
  );
}

/** Bucket tenants by their `group`, preserving first-seen order. */
function useGroupedTenants(tenants: Tenant[]) {
  return React.useMemo(() => {
    const groups = new Map<string | undefined, Tenant[]>();
    for (const tenant of tenants) {
      const bucket = groups.get(tenant.group) ?? [];
      bucket.push(tenant);
      groups.set(tenant.group, bucket);
    }
    return [...groups];
  }, [tenants]);
}

export {
  TenantSwitcher,
  TenantSwitcherTrigger,
  TenantSwitcherContent,
  TenantSwitcherItem,
  TenantSwitcherCreate,
};

Dependencies

shadcn registry

popovercommand

npm

cmdklucide-react