Skip to content
ilinxa/pro-ui

Account Switcher

alphav0.2.0

Popover account and context switcher — active label trigger, switchable context list, and a footer slot for create or request actions.

Category: NavigationUpdated: 2026-08-11Created: 2026-05-23Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/account-switcher

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/account-switcher-fixtures

Preview

(other sidebar nav rows would render below)

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

demo.tsxtsx

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 onSelect when the active item is clicked. Consumer re-affirmation requires wrapping onSelect.
  • Permissions/gating live OUTSIDE the library — pre-filter your itemsarray based on roles, memberships, plan tiers.
  • Sibling: app-sidebar v0.2.0 mounts this primitive in its topSlot. 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

Tags

account-switcherworkspace-switchercontext-switcherpopovercomboboxmulti-tenantapp-shell

Dependencies

shadcn primitives: popover, separator
npm peer deps: lucide-react@^1.11.0