ilinxa
blockaccount-switchersource: ok · hit
Account Switcher
Popover account and context switcher — active label trigger, switchable context list, and a footer slot for create or request actions.
npx shadcn add "https://ui.ilinxa.com/r/account-switcher.json"free
Where it goes
footer
Interaction
tooltip
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · lighticonslucide-react
Registry dependencies
popoverseparator
Files10 · source cached 10/9/2026, 4:49:30 AM
src/registry/components/navigation/account-switcher/account-switcher.tsx
"use client";
import { useCallback, useId, useMemo } from "react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import { useControllableState } from "./hooks/use-controllable-state";
import { enforceUniqueKeys } from "./lib/enforce-unique-keys";
import { resolveActiveItem } from "./lib/resolve-active-item";
import { EmptyPlaceholder } from "./parts/empty-placeholder";
import { SwitcherItemRow } from "./parts/switcher-item-row";
import {
SwitcherTriggerContent,
composeSwitcherTriggerAriaLabel,
switcherTriggerClassName,
} from "./parts/switcher-trigger";
import type { AccountSwitcherProps, SwitcherItem } from "./types";
const DEFAULT_ARIA_LABEL = "Switch account context";
/**
* Account Switcher — popover-with-switchable-items primitive.
*
* See [`docs/procomps/account-switcher-procomp/`](../../../../../docs/procomps/account-switcher-procomp/)
* for description + plan + guide. Locks L1–L14, all PQs at default per
* 2026-05-23 GATE 2 close.
*/
export function AccountSwitcher(props: AccountSwitcherProps) {
const {
items,
activeKey,
onSelect,
fallbackActiveItem,
footerSlot,
isCollapsed = false,
collapsedPopoverSide = "right",
"aria-label": ariaLabel = DEFAULT_ARIA_LABEL,
ariaCurrent = "true",
open: openProp,
defaultOpen,
onOpenChange,
className,
} = props;
const listboxId = useId();
const [open, setOpen] = useControllableState<boolean>({
value: openProp,
defaultValue: defaultOpen ?? false,
onChange: onOpenChange,
componentName: "account-switcher",
valuePropName: "open",
});
// L3 + Q2 — dev-warn + strip duplicates once per items reference change.
const dedupedItems = useMemo(() => enforceUniqueKeys(items), [items]);
const activeResolution = useMemo(
() => resolveActiveItem(dedupedItems, activeKey, fallbackActiveItem),
[dedupedItems, activeKey, fallbackActiveItem],
);
const activeItem = activeResolution.kind === "empty" ? null : activeResolution.item;
// F-cross-13 guard at the shadcn-primitive boundary. The consumer's
// `onOpenChange` is shielded — `useControllableState` always calls it with
// the validated `boolean`. Plan §10.
const handlePrimitiveOpenChange = useCallback(
(next: unknown) => {
if (typeof next !== "boolean") return;
setOpen(next);
},
[setOpen],
);
// L6 — active-item clicks close the popover but do NOT fire onSelect.
const handleItemClick = useCallback(
(item: SwitcherItem) => {
if (item.key === activeItem?.key) {
setOpen(false);
return;
}
onSelect(item);
setOpen(false);
},
[activeItem?.key, onSelect, setOpen],
);
// Empty-state branch (Q1)
if (activeResolution.kind === "empty") {
return (
<EmptyPlaceholder
ariaLabel={ariaLabel}
isCollapsed={isCollapsed}
className={className}
/>
);
}
const popoverSide = isCollapsed ? collapsedPopoverSide : "bottom";
const popoverWidthStyle = isCollapsed
? undefined
: ({ width: "var(--radix-popover-trigger-width)" } as React.CSSProperties);
return (
<Popover open={open} onOpenChange={handlePrimitiveOpenChange}>
{/* v0.1.1 (F-cross-13 path-b): no `asChild` — Base UI's PopoverTrigger
rejects it. The trigger IS the combobox button: both backends render
a native <button> and pass native DOM props straight through. */}
<PopoverTrigger
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listboxId}
aria-label={composeSwitcherTriggerAriaLabel(ariaLabel, activeItem)}
className={switcherTriggerClassName({ isCollapsed, className })}
>
<SwitcherTriggerContent activeItem={activeItem} isCollapsed={isCollapsed} />
</PopoverTrigger>
<PopoverContent
side={popoverSide}
align="start"
sideOffset={4}
// No collisionPadding — Radix-only prop; Base UI's PopoverContent
// rejects it (F-cross-13). Both backends still collision-flip.
style={popoverWidthStyle}
className={cn(
"w-auto min-w-56 gap-0 p-1",
// override the primitive's baked-in w-72; Radix exposes
// --radix-popover-trigger-width, Base UI --anchor-width (F-cross-13)
!isCollapsed && "w-(--radix-popover-trigger-width,var(--anchor-width))",
)}
>
<ul id={listboxId} role="listbox" aria-label={ariaLabel} className="flex flex-col gap-0.5">
{dedupedItems.map((item) => (
<SwitcherItemRow
key={item.key}
item={item}
isActive={item.key === activeItem?.key}
ariaCurrent={ariaCurrent}
onSelect={() => handleItemClick(item)}
/>
))}
</ul>
{footerSlot ? (
<>
<Separator className="my-1" />
<div className="px-1 pb-1">{footerSlot}</div>
</>
) : null}
</PopoverContent>
</Popover>
);
}
src/registry/components/navigation/account-switcher/index.ts
export { AccountSwitcher } from "./account-switcher";
export type {
AccountSwitcherProps,
AccountSwitcherAriaCurrent,
CollapsedPopoverSide,
SwitcherItem,
} from "./types";
src/registry/components/navigation/account-switcher/types.ts
import type { ComponentType, ReactNode } from "react";
/**
* Single item in the switcher list.
*
* Dual-entry items for the same conceptual account (e.g., business-mode +
* cms-sub-mode of the same business) use distinct keys like `biz-acme` +
* `cms-biz-acme`. Library enforces key uniqueness at render (L3 / Q2).
*/
export interface SwitcherItem {
/** Stable unique key. Used for active resolution + React reconciliation. */
key: string;
/** Trigger + row label. Library does NOT i18n; consumer pre-translates. */
label: string;
/**
* Optional icon. Library accepts both ReactNode (already-rendered JSX) and
* ComponentType (lucide-react icons, custom icon components, etc.) so
* consumers aren't forced into a single icon library (I-4).
*/
icon?: ReactNode | ComponentType<{ className?: string }>;
/**
* Optional href. The switcher fires `onSelect(item)` regardless of href —
* consumer wires routing inside `onSelect` (router.push for SPA, anchor for
* SSR fallbacks, or both). Library does not render `<a>` tags itself.
*/
href?: string;
}
/**
* Value applied to the active item's `aria-current` attribute. Default
* `"true"` (generic active-state semantic, correct for a switcher per L14).
* Consumers using the switcher as primary navigation pick `"page"`;
* stepper-style usages pick `"step"`. Pass `false` to omit the attribute.
*/
export type AccountSwitcherAriaCurrent =
| "true"
| "page"
| "step"
| "location"
| "date"
| "time"
| false;
/**
* Side of the trigger the popover opens to when `isCollapsed` is true.
* Defaults to `"right"` (collision-aware auto-flip applied by Radix Popover).
* Override to `"left"` for right-edge sidebars, etc. (PQ1).
*/
export type CollapsedPopoverSide = "right" | "left" | "top" | "bottom";
export interface AccountSwitcherProps {
/** Ordered list. Consumer controls ordering (L2). */
items: ReadonlyArray<SwitcherItem>;
/**
* Currently-active item's key. When null OR not found in items, falls
* back to `fallbackActiveItem` (if provided) then to `items[0]`. Empty
* items + no fallback → disabled placeholder button (L4, Q1).
*/
activeKey: string | null;
/** Fires on item click. Active-item clicks are no-ops at library level (L6). */
onSelect: (item: SwitcherItem) => void;
/** Shown in trigger when `activeKey` doesn't resolve (L4, I-1). */
fallbackActiveItem?: SwitcherItem;
/** Rendered below items, separated by divider when present (L5, Q7). */
footerSlot?: ReactNode;
/** When true, trigger collapses to icon-only mode (L10). */
isCollapsed?: boolean;
/** Side the popover opens to when `isCollapsed` is true. Default `"right"` (PQ1). */
collapsedPopoverSide?: CollapsedPopoverSide;
/** Trigger ARIA label. Default `"Switch account context"` (L12). */
"aria-label"?: string;
/** Value applied to active item's `aria-current`. Default `"true"` (L14). */
ariaCurrent?: AccountSwitcherAriaCurrent;
/**
* Controlled-open state (L13). When provided, makes the popover controlled.
* Pair with `onOpenChange` to receive state-change events. Switching between
* controlled and uncontrolled (e.g., `open={undefined}` → `open={false}`)
* fires a dev-mode warning — pick one mode at mount time.
*/
open?: boolean;
/** Initial open state for uncontrolled mode (L13). Default `false`. */
defaultOpen?: boolean;
/**
* Fires when popover open state changes (L13). F-cross-13 typeof-guarded
* internally — receives only `boolean`, never `unknown`.
*/
onOpenChange?: (next: boolean) => void;
/** Pass-through to trigger element (L1 surface contract). */
className?: string;
}
src/registry/components/navigation/account-switcher/hooks/use-controllable-state.ts
import { useCallback, useEffect, useRef, useState } from "react";
interface UseControllableStateOpts<T> {
/** Controlled value. When provided, state is fully controlled by parent. */
value?: T;
/** Initial value for uncontrolled mode. */
defaultValue: T;
/** Fires on every state change (both modes). */
onChange?: (next: T) => void;
/** Component name used in dev warnings. */
componentName: string;
/** Prop name used in dev warnings (e.g., `"open"`). */
valuePropName: string;
}
/**
* Controlled+uncontrolled state-machine helper.
*
* Plan §5.1 + re-validation Finding 1 (⚠️ HIGH):
* - Locks the controlled/uncontrolled mode based on the FIRST render's
* `value` and dev-warns when consumers flip modes mid-life. Switching
* between `value={undefined}` and `value={someBoolean}` is the classic
* React anti-pattern; we don't silently swap modes.
* - Dev-warns when controlled mode is used without an `onChange` handler
* (popover would appear frozen).
*
* Both warns are gated on `process.env.NODE_ENV !== "production"` so they
* tree-shake out of prod bundles.
*
* Internal helper — not exported from the procomp's public API.
*/
export function useControllableState<T>({
value,
defaultValue,
onChange,
componentName,
valuePropName,
}: UseControllableStateOpts<T>): readonly [T, (next: T) => void] {
const [internal, setInternal] = useState<T>(defaultValue);
const isControlled = value !== undefined;
const onChangeRef = useRef(onChange);
useEffect(() => {
onChangeRef.current = onChange;
});
const wasControlledRef = useRef(isControlled);
useEffect(() => {
if (process.env.NODE_ENV === "production") return;
if (wasControlledRef.current !== isControlled) {
console.warn(
`[${componentName}] \`${valuePropName}\` switched from ${
wasControlledRef.current ? "controlled" : "uncontrolled"
} to ${
isControlled ? "controlled" : "uncontrolled"
} mode. Components should not switch modes mid-life; pick one at mount.`,
);
wasControlledRef.current = isControlled;
}
}, [isControlled, componentName, valuePropName]);
useEffect(() => {
if (process.env.NODE_ENV === "production") return;
if (isControlled && !onChangeRef.current) {
const capitalized = valuePropName.charAt(0).toUpperCase() + valuePropName.slice(1);
console.warn(
`[${componentName}] \`${valuePropName}\` is controlled but no onChange handler was provided. ` +
`State will appear frozen. Pass \`on${capitalized}Change\` ` +
`(or use the uncontrolled variant by passing \`default${capitalized}\` instead).`,
);
}
}, [isControlled, componentName, valuePropName]);
const current = isControlled ? (value as T) : internal;
const set = useCallback(
(next: T) => {
if (!isControlled) setInternal(next);
onChangeRef.current?.(next);
},
[isControlled],
);
return [current, set] as const;
}
src/registry/components/navigation/account-switcher/lib/enforce-unique-keys.ts
import type { SwitcherItem } from "../types";
/**
* Strip duplicate-key entries and dev-warn about the violation.
*
* Per L3 + Q2 — matches React's own key-uniqueness warning semantics:
* - Production: silent strip, last-write-wins-by-position (preserves first
* occurrence by source order)
* - Development: console.warn listing each duplicate key
*
* Callers should wrap in `useMemo([items])` so the dedup pass and the
* (potential) warn fire once per items-reference change, not per render.
*/
export function enforceUniqueKeys(
items: ReadonlyArray<SwitcherItem>,
): ReadonlyArray<SwitcherItem> {
const seen = new Set<string>();
const deduped: SwitcherItem[] = [];
const duplicateKeys: string[] = [];
for (const item of items) {
if (seen.has(item.key)) {
duplicateKeys.push(item.key);
continue;
}
seen.add(item.key);
deduped.push(item);
}
if (duplicateKeys.length > 0 && process.env.NODE_ENV !== "production") {
console.warn(
`[account-switcher] Duplicate item keys stripped: ${duplicateKeys.join(", ")}. ` +
`Each item must have a unique \`key\`. First occurrence preserved by source order.`,
);
}
return deduped;
}
src/registry/components/navigation/account-switcher/lib/resolve-active-item.ts
import type { SwitcherItem } from "../types";
/**
* Result of the active-item resolution pipeline (plan §6).
*
* Discriminated union so the renderer can react differently per branch —
* e.g., visually mark `kind: "fallback"` if the design later wants to, or
* disable the trigger in the `kind: "empty"` case (Q1 default).
*/
export type ResolvedActive =
| { kind: "resolved"; item: SwitcherItem }
| { kind: "fallback"; item: SwitcherItem }
| { kind: "first"; item: SwitcherItem }
| { kind: "empty" };
/**
* Resolve the item to show in the trigger.
*
* Priority (L4 + Q1):
* 1. items.find((i) => i.key === activeKey) → kind: "resolved"
* 2. fallbackActiveItem → kind: "fallback"
* 3. items[0] → kind: "first"
* 4. (none) → kind: "empty"
*/
export function resolveActiveItem(
items: ReadonlyArray<SwitcherItem>,
activeKey: string | null,
fallback: SwitcherItem | undefined,
): ResolvedActive {
if (activeKey !== null) {
const match = items.find((i) => i.key === activeKey);
if (match) return { kind: "resolved", item: match };
}
if (fallback) return { kind: "fallback", item: fallback };
if (items.length > 0) return { kind: "first", item: items[0]! };
return { kind: "empty" };
}
src/registry/components/navigation/account-switcher/parts/empty-placeholder.tsx
import { cn } from "@/lib/utils";
interface EmptyPlaceholderProps {
ariaLabel: string;
isCollapsed: boolean;
className?: string;
}
/**
* Disabled trigger rendered when `items` is empty AND no `fallbackActiveItem`
* is provided (Q1 default: disabled with placeholder text). Defensive
* against the source's empty-array crash (I-2).
*/
export function EmptyPlaceholder({ ariaLabel, isCollapsed, className }: EmptyPlaceholderProps) {
if (isCollapsed) {
return (
<button
type="button"
disabled
aria-label={ariaLabel}
className={cn(
"inline-flex h-10 w-10 cursor-not-allowed items-center justify-center rounded-md border border-dashed border-border bg-card text-muted-foreground opacity-60",
className,
)}
>
<span className="text-xs">—</span>
</button>
);
}
return (
<button
type="button"
disabled
aria-label={ariaLabel}
className={cn(
"inline-flex w-full cursor-not-allowed items-center justify-between rounded-md border border-dashed border-border bg-card px-3 py-2 text-sm text-muted-foreground opacity-60",
className,
)}
>
<span>No items available</span>
</button>
);
}
src/registry/components/navigation/account-switcher/parts/render-icon.tsx
import { createElement, isValidElement, type ComponentType, type ReactNode } from "react";
type IconLike = ReactNode | ComponentType<{ className?: string }>;
/**
* Render an icon prop that accepts either an already-rendered ReactNode
* (JSX) OR a ComponentType (lucide-react icon, custom component, forwardRef
* object). Handles all three shapes:
*
* 1. JSX element → return as-is
* 2. function → call with { className }
* 3. forwardRef / memo / exotic component (object with $$typeof) →
* use createElement so lucide-react v0.475+ forwardRef icons work
* (mirrors the app-sidebar Icon helper fix from 52e5f33).
* 4. primitive (string/number) → wrap as-is
* 5. null / undefined → null
*/
export function renderIcon(icon: IconLike | undefined, className?: string): ReactNode {
if (icon === null || icon === undefined) return null;
// Already a rendered element
if (isValidElement(icon)) return icon;
// Function component
if (typeof icon === "function") {
return createElement(icon as ComponentType<{ className?: string }>, { className });
}
// forwardRef / memo / exotic (object with $$typeof) — lucide-react v0.475+
if (typeof icon === "object" && icon !== null && "$$typeof" in icon) {
return createElement(icon as unknown as ComponentType<{ className?: string }>, { className });
}
// Primitive (string / number) — render directly
return icon as ReactNode;
}
src/registry/components/navigation/account-switcher/parts/switcher-item-row.tsx
import type { AriaAttributes, ReactNode } from "react";
import { Check } from "lucide-react";
import { cn } from "@/lib/utils";
import type { AccountSwitcherAriaCurrent, SwitcherItem } from "../types";
import { renderIcon } from "./render-icon";
interface SwitcherItemRowProps {
item: SwitcherItem;
isActive: boolean;
ariaCurrent: AccountSwitcherAriaCurrent;
onSelect: () => void;
}
/**
* Single row inside the popover listbox. Renders icon (if present) + label
* + Check trail (when active). Active rows are no-op on click (L6) —
* actual handler in the main component already routes around them.
*
* `aria-current` resolution per L14 + PQ3 (data-active for CSS hooks
* regardless of aria value).
*/
export function SwitcherItemRow({
item,
isActive,
ariaCurrent,
onSelect,
}: SwitcherItemRowProps) {
return (
<li role="presentation" className="contents">
<button
type="button"
role="option"
aria-selected={isActive || undefined}
aria-current={resolveAriaCurrent(isActive, ariaCurrent)}
data-active={isActive || undefined}
onClick={onSelect}
className={cn(
"group flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:bg-accent focus-visible:text-accent-foreground focus-visible:outline-none",
isActive && "bg-accent/60 text-accent-foreground",
)}
>
{item.icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground group-hover:text-foreground">
{renderIcon(item.icon, "h-4 w-4") as ReactNode}
</span>
) : null}
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{isActive ? (
<Check className="ml-auto h-4 w-4 shrink-0 text-foreground" aria-hidden="true" />
) : null}
</button>
</li>
);
}
function resolveAriaCurrent(
isActive: boolean,
override: AccountSwitcherAriaCurrent,
): AriaAttributes["aria-current"] | undefined {
if (!isActive) return undefined;
if (override === false) return undefined;
return override;
}
src/registry/components/navigation/account-switcher/parts/switcher-trigger.tsx
import { forwardRef, type ReactNode } from "react";
import { ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import type { SwitcherItem } from "../types";
import { renderIcon } from "./render-icon";
interface SwitcherTriggerProps {
/** Resolved active item (may be a fallback / first / etc.). */
activeItem: SwitcherItem | null;
/** Trigger ARIA label. Composed with active label per PQ4. */
ariaLabel: string;
/** ID of the listbox the trigger controls (jsx-a11y combobox requirement). */
ariaControls: string;
/** When true, render icon-only 40x40 square; no label, no chevron (L10). */
isCollapsed: boolean;
/** Whether the popover is currently open (drives aria-expanded). */
open: boolean;
/** Whether the trigger is disabled (e.g., empty-items state). */
disabled?: boolean;
/** Pass-through className from props. */
className?: string;
/** Click handler (Popover.Trigger native). */
onClick?: () => void;
}
/**
* PQ4 — composed aria-label includes the active item label when one resolves
* ("Switch account context, current: Acme Corp").
*/
export function composeSwitcherTriggerAriaLabel(
ariaLabel: string,
activeItem: SwitcherItem | null,
): string {
return activeItem ? `${ariaLabel}, current: ${activeItem.label}` : ariaLabel;
}
/**
* Trigger button classes — two branches per `isCollapsed`:
* - expanded: full-width with icon + label + chevron, width drives popover
* width via --radix-popover-trigger-width
* - collapsed: 40x40 icon-only square; popover opens to the side
*
* v0.1.1 (F-cross-13 path-b): exported as a class builder because the
* assembly no longer mounts a <button> via `<PopoverTrigger asChild>` —
* Base UI's PopoverTrigger has no `asChild` (and the CLI's render-rewrite
* breaks mixed consumers). The primitive trigger IS the button now:
* account-switcher.tsx renders
* `<PopoverTrigger className={switcherTriggerClassName(…)}>` with
* `<SwitcherTriggerContent>` inside, passing only native DOM props across
* the shadcn-primitive boundary.
*/
export function switcherTriggerClassName({
isCollapsed,
className,
}: {
isCollapsed: boolean;
className?: string;
}): string {
if (isCollapsed) {
return cn(
"inline-flex h-10 w-10 items-center justify-center rounded-md border border-border bg-card text-foreground transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
);
}
return cn(
"inline-flex w-full items-center gap-2 rounded-md border border-border bg-card px-3 py-2 text-sm text-foreground transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-50",
className,
);
}
/**
* Inner trigger content (icon / initial / label / chevron), backend-neutral —
* rendered inside whichever button element hosts the trigger.
*/
export function SwitcherTriggerContent({
activeItem,
isCollapsed,
}: {
activeItem: SwitcherItem | null;
isCollapsed: boolean;
}) {
if (isCollapsed) {
return activeItem?.icon ? (
<>{renderIcon(activeItem.icon, "h-4 w-4") as ReactNode}</>
) : (
<span className="text-xs font-medium uppercase">
{activeItem?.label?.charAt(0) ?? "?"}
</span>
);
}
return (
<>
{activeItem?.icon ? (
<span className="flex h-4 w-4 shrink-0 items-center justify-center text-muted-foreground">
{renderIcon(activeItem.icon, "h-4 w-4") as ReactNode}
</span>
) : null}
<span className="min-w-0 flex-1 truncate text-left">
{activeItem?.label ?? "—"}
</span>
<ChevronsUpDown
className="ml-auto h-4 w-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</>
);
}
/**
* Standalone trigger button — v0.1.0 shape, byte-identical rendering, kept
* for API stability (the part file ships; direct importers keep working).
* Since v0.1.1 the assembly no longer routes it through
* `<PopoverTrigger asChild>` (F-cross-13) — see switcherTriggerClassName
* above for the primitive-trigger path.
*/
export const SwitcherTrigger = forwardRef<HTMLButtonElement, SwitcherTriggerProps>(
function SwitcherTrigger(
{ activeItem, ariaLabel, ariaControls, isCollapsed, open, disabled, className, onClick, ...rest },
ref,
) {
return (
<button
ref={ref}
type="button"
role="combobox"
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={ariaControls}
aria-label={composeSwitcherTriggerAriaLabel(ariaLabel, activeItem)}
disabled={disabled}
onClick={onClick}
className={switcherTriggerClassName({ isCollapsed, className })}
{...rest}
>
<SwitcherTriggerContent activeItem={activeItem} isCollapsed={isCollapsed} />
</button>
);
},
);