A workspace picker: the current team on a button, the others in a radio menu with logos and descriptions, an add-team row, and a compact icon mode.
Working in Acme Inc.
"use client";
import * as React from "react";
import { TeamSwitcher } from "@/components/ballmac/team-switcher";
const initial = [
{ id: "acme", name: "Acme Inc.", description: "Pro · 12 members" },
{ id: "design", name: "Design Lab", description: "Team · 5 members" },
{ id: "labs", name: "Orbit Studio", description: "Free · 3 members" },
];
export default function TeamSwitcherDemo() {
const [teams, setTeams] = React.useState(initial);
const [value, setValue] = React.useState("acme");
return (
<div className="grid w-full max-w-xs gap-3 rounded-xl border bg-card p-3 shadow-sm">
<TeamSwitcher
teams={teams}
value={value}
onValueChange={setValue}
onAddTeam={() => {
const n = teams.length + 1;
setTeams([...teams, { id: `team-${n}`, name: `New team ${n}`, description: "Free · 1 member" }]);
}}
/>
<p className="px-1 text-xs text-muted-foreground" aria-live="polite">
Working in <span className="font-medium text-foreground">{teams.find((t) => t.id === value)?.name}</span>
</p>
</div>
);
}Installation
$ pnpm dlx shadcn@latest add @ballmac/team-switcherInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/dropdown-menu @ballmac/i18n @ballmac/mediaCopy the source into your project.
components/ballmac/team-switcher.tsx// Ballmac UI: Team Switcher. https://ui.ballmac.com/components/team-switcher "use client"; import * as React from "react"; import { Check, ChevronsUpDown, Plus } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ballmac/dropdown-menu"; import { cn } from "@/lib/utils"; import { useMessages } from "@/lib/ballmac/i18n"; import { Media, isMediaImage, type MediaSource } from "@/components/ballmac/media"; type Team = { /** Unique id; used for `value`. */ id: string; /** Team or workspace name. */ name: string; /** Secondary line, such as the plan or member count. */ description?: string; /** Logo or avatar: an image URL, an object with alt text and a dark-mode file, or any element. Defaults to the first letter of the name. */ logo?: MediaSource; }; type TeamSwitcherProps = Omit<React.ComponentProps<"button">, "value" | "defaultValue" | "onChange"> & { /** Teams to choose from. */ teams: Team[]; /** Controlled selected team id. */ value?: string; /** Initial team id when uncontrolled. */ defaultValue?: string; /** Called with the chosen team id. */ onValueChange?: (id: string) => void; /** Shows an "Add team" row at the end of the menu and calls this when chosen. */ onAddTeam?: () => void; /** Label of the add row. */ addLabel?: string; /** Show only the logo (for a collapsed sidebar). The name stays available to screen readers. */ compact?: boolean; /** Menu alignment relative to the trigger. */ align?: "start" | "center" | "end"; /** Side of the trigger the menu opens on. */ side?: "top" | "right" | "bottom" | "left"; }; function Logo({ team, className }: { team: Team; className?: string }) { return ( <span aria-hidden="true" className={cn( "flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary text-sm font-semibold text-primary-foreground [&_svg]:size-4", className, )} > {isMediaImage(team.logo) ? <Media media={team.logo} alt="" fit="contain" fill className="size-full" /> : (team.logo ?? team.name.charAt(0).toUpperCase())} </span> ); } /** A workspace picker: the current team on a button, the others in a radio menu, and an optional add row. */ function TeamSwitcher({ teams, value: valueProp, defaultValue, onValueChange, onAddTeam, addLabel, compact = false, align = "start", side = "bottom", className, ...props }: TeamSwitcherProps) { const msg = useMessages() addLabel ??= msg("team-switcher.addLabel", "Add team") const [inner, setInner] = React.useState(defaultValue ?? teams[0]?.id); const value = valueProp ?? inner; const active = teams.find((t) => t.id === value) ?? teams[0]; if (!active) return null; return ( <DropdownMenu> <DropdownMenuTrigger data-slot="team-switcher" aria-label={compact ? `Team: ${active.name}` : undefined} className={cn( "flex w-full min-w-0 items-center gap-2.5 rounded-lg border bg-background p-1.5 pe-2 text-start shadow-xs outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 data-[state=open]:bg-accent", compact && "size-11 justify-center p-1.5 pe-1.5", className, )} {...props} > <Logo team={active} /> {!compact && ( <> <span className="grid min-w-0 flex-1 leading-tight"> <span className="truncate text-sm font-semibold">{active.name}</span> {active.description && <span className="truncate text-xs text-muted-foreground">{active.description}</span>} </span> <ChevronsUpDown aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" /> </> )} </DropdownMenuTrigger> <DropdownMenuContent align={align} side={side} className="w-(--radix-dropdown-menu-trigger-width) min-w-60"> <DropdownMenuLabel>{msg("team-switcher.teams", "Teams")}</DropdownMenuLabel> <DropdownMenuRadioGroup value={value} onValueChange={(id) => { if (valueProp === undefined) setInner(id); onValueChange?.(id); }} > {teams.map((team) => ( <DropdownMenuRadioItem key={team.id} value={team.id} className="gap-2.5 py-1.5 ps-2 [&>span:first-child]:hidden"> <Logo team={team} className="size-7 rounded-md text-xs" /> <span className="grid min-w-0 flex-1 leading-tight"> <span className="truncate text-sm">{team.name}</span> {team.description && <span className="truncate text-xs text-muted-foreground">{team.description}</span>} </span> {team.id === value && <Check aria-hidden="true" className="ms-auto size-4 !text-foreground" />} </DropdownMenuRadioItem> ))} </DropdownMenuRadioGroup> {onAddTeam && ( <> <DropdownMenuSeparator /> <DropdownMenuItem onSelect={onAddTeam} className="gap-2.5 py-1.5"> <span className="flex size-7 items-center justify-center rounded-md border border-dashed text-muted-foreground"> <Plus aria-hidden="true" className="size-4" /> </span> {addLabel} </DropdownMenuItem> </> )} </DropdownMenuContent> </DropdownMenu> ); } export { TeamSwitcher, type TeamSwitcherProps, type Team };Update the import paths to match your project setup.
Usage
import { TeamSwitcher } from "@/components/ballmac/team-switcher"The full example is in the Code tab above.
Examples
Compact
Compact mode for a collapsed sidebar. The menu opens to the side.
import { Building2, Leaf, Rocket } from "lucide-react";
import { TeamSwitcher } from "@/components/ballmac/team-switcher";
const teams = [
{ id: "hq", name: "Headquarters", description: "Enterprise", logo: <Building2 /> },
{ id: "green", name: "Green Team", description: "Team", logo: <Leaf /> },
{ id: "launch", name: "Launch Crew", description: "Pro", logo: <Rocket /> },
];
export default function TeamSwitcherStates() {
return (
<div className="flex items-center gap-4 rounded-xl border bg-card p-3">
<div className="w-14">
<TeamSwitcher teams={teams} compact side="right" />
</div>
<p className="max-w-56 text-sm text-muted-foreground">Compact mode for a collapsed sidebar. The menu opens to the side.</p>
</div>
);
}API reference
| Prop | Type | Default |
|---|---|---|
teams*Teams to choose from. | Team[] | — |
valueControlled selected team id. | string | — |
defaultValueInitial team id when uncontrolled. | string | — |
onValueChangeCalled with the chosen team id. | (id: string) => void | — |
onAddTeamShows an "Add team" row at the end of the menu and calls this when chosen. | () => void | — |
addLabelLabel of the add row. | string | — |
compactShow only the logo (for a collapsed sidebar). The name stays available to screen readers. | boolean | false |
alignMenu alignment relative to the trigger. | "start" | "center" | "end" | "start" |
sideSide of the trigger the menu opens on. | "top" | "right" | "bottom" | "left" | "bottom" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Enter / Space / ArrowDown | Opens the menu |
| ArrowUp / ArrowDown | Moves between teams |
| Enter | Chooses the team |
| Escape | Closes and returns focus |
Use with AI
teams: {id,name,description,logo}. value/defaultValue/onValueChange choose the active team; onAddTeam adds the last row; compact shows only the logo. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Team Switcher (@ballmac/team-switcher) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Multi-workspace apps in a sidebar or header
- Switching organizations or projects
Not for
- Choosing a value in a form; use select or combobox
- Long searchable lists; use combobox
Registry JSON: https://ui.ballmac.com/r/team-switcher.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
Pairs well with
Sidebar
An app sidebar with icon-rail collapse, floating and inset variants, grouped menus with badges and sub-items, a mobile sheet, tooltips when collapsed and a Cmd/Ctrl+B shortcut.
App Shell
The frame of an application page: skip link, sticky header, sidebar that becomes a sheet on small screens, main area, optional aside and footer.
Avatar
A Radix avatar with image and initials fallback, three sizes, an optional presence dot, and AvatarGroup for overlapping stacks with a +N overflow counter.
Animated Tabs
Radix Tabs with a pill or underline indicator that slides between triggers on a spring, and panels that fade in. Keeps full keyboard support and ARIA; controlled or uncontrolled.