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 initpnpm dlx shadcn@latest add @ilinxa/account-switcherAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/account-switcher-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
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
"use client"; import { useMemo, useState } from "react";import { Plus, Building2, User } from "lucide-react";import { AccountSwitcher } from "./account-switcher";import { ACCOUNT_SWITCHER_DUMMY_ACTIVE_KEY, ACCOUNT_SWITCHER_DUMMY_FALLBACK, ACCOUNT_SWITCHER_DUMMY_ITEMS,} from "./dummy-data"; const TAB_KEYS = ["sidebar", "topbar", "collapsed", "fallback", "controlled"] as const;type TabKey = (typeof TAB_KEYS)[number]; const TAB_LABELS: Record<TabKey, string> = { sidebar: "In a sidebar", topbar: "Standalone in topbar", collapsed: "Collapsed mode", fallback: "Fallback active item", controlled: "Controlled open",}; export default function AccountSwitcherDemo() { const [tab, setTab] = useState<TabKey>("sidebar"); return ( <div className="flex flex-col gap-4"> <div role="tablist" className="flex flex-wrap gap-1 border-b border-border pb-1"> {TAB_KEYS.map((key) => ( <button key={key} role="tab" type="button" aria-selected={tab === key} onClick={() => setTab(key)} className={ tab === key ? "rounded-md bg-accent px-3 py-1.5 text-sm font-medium text-accent-foreground" : "rounded-md px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent/40" } > {TAB_LABELS[key]} </button> ))} </div> <div className="rounded-lg border border-border bg-muted/30 p-6"> {tab === "sidebar" && <SidebarDemo />} {tab === "topbar" && <TopbarDemo />} {tab === "collapsed" && <CollapsedDemo />} {tab === "fallback" && <FallbackDemo />} {tab === "controlled" && <ControlledDemo />} </div> </div> );} function SidebarDemo() { const [activeKey, setActiveKey] = useState<string>( ACCOUNT_SWITCHER_DUMMY_ACTIVE_KEY ?? "personal", ); return ( <div className="flex flex-col gap-4 md:flex-row md:gap-6"> <div className="flex w-full flex-col gap-3 rounded-lg border border-border bg-card p-3 md:w-64"> <AccountSwitcher items={ACCOUNT_SWITCHER_DUMMY_ITEMS} activeKey={activeKey} onSelect={(item) => setActiveKey(item.key)} footerSlot={ <button type="button" className="inline-flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-accent" > <Plus className="h-4 w-4" /> <span>Create new business</span> </button> } /> <div className="text-xs text-muted-foreground"> (other sidebar nav rows would render below) </div> </div> <div className="flex-1 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground"> <p className="mb-2 font-medium text-foreground">Canonical use:</p> <p>Mount the switcher inside a sidebar shell — it occupies the “top zone” (above the brand row in <code>app-sidebar</code> v0.2.0's upcoming <code>topSlot</code>). Footer slot is the consumer-owned escape hatch for “Create new” / “Request access” / multi-state state machines.</p> <p className="mt-3">Active key: <code>{activeKey}</code></p> </div> </div> );} function TopbarDemo() { const [activeKey, setActiveKey] = useState<string>("biz-acme"); return ( <div className="flex flex-col gap-6"> <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border bg-card px-4 py-3"> <div className="flex items-center gap-3 text-sm font-medium text-foreground"> <span className="text-base">○ MyApp</span> </div> <div className="order-3 w-full min-w-0 sm:order-0 sm:w-60"> <AccountSwitcher items={ACCOUNT_SWITCHER_DUMMY_ITEMS} activeKey={activeKey} onSelect={(item) => setActiveKey(item.key)} /> </div> <div className="flex items-center gap-2 text-sm text-muted-foreground"> <User className="h-4 w-4" /> <span>Account</span> </div> </div> <p className="text-sm text-muted-foreground"> Standalone in a topbar — no sidebar required. The switcher is just a primitive; consumers slot it wherever a “current context + switchable other contexts” UI fits. </p> </div> );} function CollapsedDemo() { const [activeKey, setActiveKey] = useState<string>("biz-acme"); return ( <div className="flex flex-col gap-4 md:flex-row md:gap-6"> <div className="flex w-16 flex-col items-center gap-3 self-start rounded-lg border border-border bg-card p-2"> <AccountSwitcher items={ACCOUNT_SWITCHER_DUMMY_ITEMS} activeKey={activeKey} onSelect={(item) => setActiveKey(item.key)} isCollapsed aria-label="Switch workspace" footerSlot={ <button type="button" className="inline-flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-foreground hover:bg-accent" > <Plus className="h-4 w-4" /> <span>Create new</span> </button> } /> <div className="h-px w-full bg-border" /> <div className="flex flex-col items-center gap-2 text-xs text-muted-foreground"> <Building2 className="h-4 w-4" /> <span className="rotate-90 text-[10px]">⋯</span> </div> </div> <div className="flex-1 rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground"> <p className="mb-2 font-medium text-foreground">Icon-only trigger (L10):</p> <p> When <code>isCollapsed</code> is true, the trigger renders as a 40×40 square. The popover opens to the side (default <code>"right"</code>; override via <code>collapsedPopoverSide</code>). List rows + footer slot still render the full content; only positioning + width differ. </p> <p className="mt-3">Active key: <code>{activeKey}</code></p> </div> </div> );} function FallbackDemo() { const [activeKey, setActiveKey] = useState<string | null>("not-in-list"); return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap items-center gap-3 text-sm"> <label htmlFor="active-key-input" className="font-medium text-foreground"> activeKey: </label> <input id="active-key-input" type="text" value={activeKey ?? ""} onChange={(e) => setActiveKey(e.target.value || null)} className="w-full min-w-0 flex-1 rounded-md border border-border bg-card px-2 py-1 font-mono text-xs sm:w-48 sm:flex-none" /> <span className="text-xs text-muted-foreground"> (try blank, "personal", or any unmatched string) </span> </div> <div className="w-full max-w-sm"> <AccountSwitcher items={ACCOUNT_SWITCHER_DUMMY_ITEMS} activeKey={activeKey} onSelect={(item) => setActiveKey(item.key)} fallbackActiveItem={ACCOUNT_SWITCHER_DUMMY_FALLBACK} /> </div> <p className="text-sm text-muted-foreground"> I-1: when <code>activeKey</code> doesn't resolve to an item, the trigger shows the explicit <code>fallbackActiveItem</code> instead of mis-labeling (the source's governance-as-Personal bug). Demonstrates the priority pipeline: <code> match → fallback → items[0] → empty</code>. </p> </div> );} function ControlledDemo() { const [open, setOpen] = useState(false); const [activeKey, setActiveKey] = useState<string>("biz-acme"); const events = useMemo(() => [] as string[], []); const [log, setLog] = useState<string[]>([]); return ( <div className="flex flex-col gap-4"> <div className="flex flex-wrap items-center gap-3"> <button type="button" onClick={() => setOpen(true)} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90" > Open programmatically </button> <button type="button" onClick={() => setOpen(false)} className="rounded-md border border-border bg-card px-3 py-1.5 text-sm text-foreground hover:bg-accent" > Close programmatically </button> <span className="text-xs text-muted-foreground"> open: <code>{String(open)}</code> </span> </div> <div className="w-full max-w-sm"> <AccountSwitcher items={ACCOUNT_SWITCHER_DUMMY_ITEMS} activeKey={activeKey} onSelect={(item) => { setActiveKey(item.key); setLog((prev) => [...prev, `onSelect: ${item.key}`].slice(-6)); }} open={open} onOpenChange={(next) => { setOpen(next); setLog((prev) => [...prev, `onOpenChange: ${next}`].slice(-6)); }} /> </div> <p className="text-sm text-muted-foreground"> L13: controlled-open triplet (<code>open</code> / <code>defaultOpen</code> / <code> onOpenChange</code>) ships from v0.1 per the dynamicity-primacy rule. Consumers wire Cmd+K openers, onboarding flows, test harnesses without waiting for v0.2. </p> <div className="rounded-md bg-muted p-3 font-mono text-xs"> {log.length === 0 ? ( <span className="text-muted-foreground">(events appear here)</span> ) : ( log.map((line, i) => ( <div key={`${i}-${line}`}>{line}</div> )) )} {events.length > 0 ? null : null} </div> </div> );} 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