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
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

CLI 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

(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
"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 &ldquo;top zone&rdquo; (above the brand row in <code>app-sidebar</code> v0.2.0&apos;s upcoming <code>topSlot</code>). Footer slot is the consumer-owned escape hatch for &ldquo;Create new&rdquo; / &ldquo;Request access&rdquo; / 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 &ldquo;current context + switchable other contexts&rdquo; 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>&quot;right&quot;</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, &quot;personal&quot;, 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&apos;t resolve to an item, the trigger shows the        explicit <code>fallbackActiveItem</code> instead of mis-labeling (the source&apos;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 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