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.jsonAPI
<TenantSwitcher />
| Prop | Type | Default |
|---|---|---|
value | string | — |
defaultValue | string | — |
onValueChange | ((value: string | undefined) => void) | — |
tenants | Tenant[] | [] |
open | boolean | — |
defaultOpen | boolean | false |
onOpenChange | ((open: boolean) => void) | — |
disabled | boolean | — |
children | React.ReactNode | — |
<TenantSwitcherTrigger />
+ native element props| Prop | Type | Default |
|---|---|---|
placeholder | string | "Select workspace" |
children | React.ReactNode | — |
<TenantSwitcherContent />
+ native element props| Prop | Type | Default |
|---|---|---|
searchable | boolean | true |
searchPlaceholder | string | "Find workspace…" |
emptyMessage | string | "No workspaces found." |
footerPinned below the scrolling list, e.g. a <TenantSwitcherCreate>. | React.ReactNode | — |
<TenantSwitcherItem />
+ native element props| Prop | Type | Default |
|---|---|---|
tenant* | Tenant | — |
children | React.ReactNode | — |
<TenantSwitcherCreate />
+ native element propsNo 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