Account Switcher
alphav0.2.0Popover account and context switcher — active label trigger, switchable context list, and a footer slot for create or request actions.
Context
Every multi-tenant SaaS surface has the same widget at the top of its app-shell: a button labeled with the active workspace / account / project / sub-account / team that opens a list of switchable contexts. Linear, Notion, Vercel, Slack, GitHub, Figma — same shape, every time. `account-switcher` ships that pattern as a single primitive: active-context-aware popover with `fallbackActiveItem` so the trigger never mis-labels, controlled+uncontrolled open state from v0.1 (`open` / `defaultOpen` / `onOpenChange`), collapse-to-icon mode for slotting into `app-sidebar`'s collapsed sidebar, and an arbitrary `footerSlot` so consumers drop in their own state-machine widgets without the library taking on their domain. Canonical occupant of `app-sidebar` v0.2.0's new `topSlot`; works standalone in any context where 'current X + switchable other X's' is the UX.
Installation
pnpm dlx shadcn@latest init"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}pnpm dlx shadcn@latest add @ilinxa/account-switcherAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/account-switcher-fixturesPreview
Canonical use:
Mount the switcher inside a sidebar shell — it occupies the “top zone” (above the brand row in app-sidebar v0.2.0's upcoming topSlot). Footer slot is the consumer-owned escape hatch for “Create new” / “Request access” / multi-state state machines.
Active key: biz-acme
Demo source
Usage
When to use
Reach for AccountSwitcherany time the UI needs a “current X + switchable other X's” affordance — workspace pickers, multi-account dropdowns, governance/context mode switchers, sub-account selectors. The library renders; consumers derive items, activeKey, and footer content outside.
Pattern 1 — Basic
import { AccountSwitcher } from "@/components/account-switcher";
import { Building2, User } from "lucide-react";
function Example() {
const [activeKey, setActiveKey] = useState("biz-acme");
return (
<AccountSwitcher
items={[
{ key: "personal", label: "Personal", icon: User },
{ key: "biz-acme", label: "Acme Corp", icon: Building2, href: "/biz/acme" },
]}
activeKey={activeKey}
onSelect={(item) => {
setActiveKey(item.key);
if (item.href) router.push(item.href);
}}
/>
);
}Pattern 2 — Footer slot with create affordance
The footerSlotis arbitrary content separated from the items list by a divider. Consumers can render simple buttons or multi-state state machines (e.g., 6-state “Request access / Pending review / Available in 3 days” widgets).
<AccountSwitcher
items={items}
activeKey={activeKey}
onSelect={onSelect}
footerSlot={
canCreate ? (
<Button onClick={openCreateDialog}>
<Plus className="mr-2 h-4 w-4" /> Create Business
</Button>
) : (
<RequestAccessButton />
)
}
/>Pattern 3 — Controlled-open state
Wire open / onOpenChange to open the popover from a keyboard shortcut, tutorial flow, or test harness. onOpenChange is F-cross-13 typeof-guarded internally — consumers always receive boolean.
const [open, setOpen] = useState(false);
useHotkeys("mod+k", () => setOpen(true));
<AccountSwitcher
items={items}
activeKey={activeKey}
onSelect={onSelect}
open={open}
onOpenChange={setOpen}
/>Dev-warns fire if you flip between controlled (open=false) and uncontrolled (open=undefined) mid-life, or if you pass open withoutonOpenChange (popover would freeze).
Pattern 4 — Collapsed (icon-only) trigger
When slotted into a collapsed icon-only sidebar (e.g., app-sidebar's collapse mode), pass isCollapsed to render a 40×40 square trigger. The popover content still shows full labels.
<AccountSwitcher
items={items}
activeKey={activeKey}
onSelect={onSelect}
isCollapsed={sidebarIsCollapsed}
collapsedPopoverSide="right" // default; flip to "left" on right-edge sidebars
/>Pattern 5 — Fallback label for un-resolved active key
If activeKeydoesn't resolve to any item (async loading, route mismatch, governance context), fallbackActiveItem takes over so the trigger never mis-labels.
<AccountSwitcher
items={items}
activeKey={derivedKey}
fallbackActiveItem={{ key: "fallback", label: "Select workspace", icon: User }}
onSelect={onSelect}
/>Notes
- Items must have unique
keys. The library dev-warns + strips duplicates; React would also key-warn but you get our message first. - Active-item clicks are a no-op (L6). The library closes the popover but does NOT fire
onSelectwhen the active item is clicked. Consumer re-affirmation requires wrappingonSelect. - Permissions/gating live OUTSIDE the library — pre-filter your
itemsarray based on roles, memberships, plan tiers. - Sibling:
app-sidebarv0.2.0 mounts this primitive in itstopSlot. Zero hard dep — works standalone everywhere.
Features
- Combobox-aria popover (matches Linear / Vercel / GitHub switchers)
- fallbackActiveItem for un-resolved active keys (avoids governance-mislabel bug from source)
- Controlled+uncontrolled open state from v0.1 (open / defaultOpen / onOpenChange)
- Programmable ariaCurrent (default 'true', overridable to 'page' / 'step' / etc.)
- Collapse-to-icon trigger mode for slotting into icon-only sidebars
- Arbitrary footerSlot — consumer drops Create / Request / Settings / sign-out anywhere
- Width-matches-trigger popover via --radix-popover-trigger-width
- Dev-warns: duplicate keys stripped; controlled↔uncontrolled transition flagged
- F-cross-13 pre-emption on Popover.onOpenChange from day one
- v0.1.1 (2026-08-11) — F-cross-13 path-b sweep: no asChild — PopoverTrigger IS the combobox button (native DOM props only). Zero public-API change.
- Domain-agnostic — zero auth/membership/router imports