iOS account picker — pinned current account with badge, tappable alternate profiles, add/manage rows, sign-out-all footer
import * as React from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Badge, Glass, Icon, Text } from "@lumioui/ui";
import { useHaptic, useTheme } from "@lumioui/core";
import { Mount, Press, World } from "../shared";
export interface SwitchableAccount {
id: string;
name: string;
email: string;
avatarUrl?: string;
/** Extra context — e.g. "3 unread" or a workspace role. */
meta?: string;
}
export interface AccountSwitcherScreenProps {
accounts?: SwitchableAccount[];
/** The account currently active — rendered as the pinned header row. */
activeAccountId?: string;
onSwitch?: (account: SwitchableAccount) => void;
onAddAccount?: () => void;
onManageAccounts?: () => void;
onSignOutAll?: () => void;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_ACCOUNTS: SwitchableAccount[] = [
{ id: "personal", name: "Amara Chen", email: "amara.chen@lumio.dev", avatarUrl: face(9), meta: "Personal" },
{ id: "work", name: "Amara Chen", email: "amara@nordstandard.co", avatarUrl: face(47), meta: "Nord Standard · Admin" },
{ id: "studio", name: "Studio M", email: "hello@studiom.design", avatarUrl: face(68), meta: "Guest" },
];
/** Account switcher — the Gmail/Slack idiom: dimmed context behind a floating
* bottom sheet of stacked identity rows, not a full settings page. The active
* account reads as the header of the sheet; alternates select with a filled
* check; utilities close the sheet. */
export function AccountSwitcherScreen({
accounts = DEFAULT_ACCOUNTS,
activeAccountId = "personal",
onSwitch,
onAddAccount,
onManageAccounts,
onSignOutAll,
}: AccountSwitcherScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const { colors } = useTheme();
const active = accounts.find((a) => a.id === activeAccountId) ?? accounts[0];
const others = accounts.filter((a) => a.id !== active?.id);
if (!active) return null;
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#0C0B14]">
{/* Ghost context — what the sheet floats over. */}
<View className="flex-1 px-6" style={{ paddingTop: insets.top + 20 }} accessibilityElementsHidden>
<View className="flex-row items-center gap-3 opacity-30">
<Avatar size="sm" fallback={active.name} />
<View className="h-3 w-24 rounded-full bg-white/20" />
</View>
<View className="mt-6 gap-3 opacity-20">
<View className="h-24 rounded-3xl bg-white/10" />
<View className="h-24 rounded-3xl bg-white/10" />
<View className="h-24 rounded-3xl bg-white/10" />
</View>
</View>
<View className="absolute inset-0 bg-black/45" />
{/* Floating sheet — rounded card pinned to the bottom edge. */}
<Mount delay={60} className="absolute inset-x-3 bottom-3">
<View
className="overflow-hidden rounded-[28px] px-2 pb-2 pt-3"
style={{ paddingBottom: insets.bottom + 8 }}
accessibilityRole="menu"
accessibilityLabel="Switch account"
>
<Glass interactive
effect="regular"
sheen={0.55}
tintColor={`${colors.card}E6`}
className="absolute inset-0 rounded-[28px]"
pointerEvents="none"
/>
<View className="mb-2 h-1 w-10 self-center rounded-full bg-foreground/15" />
{/* Active account — pinned header inside the sheet. */}
<View className="flex-row items-center gap-3.5 px-4 pb-3 pt-1">
<Avatar
size="lg"
source={active.avatarUrl ? { uri: active.avatarUrl } : undefined}
fallback={active.name}
alt={active.name}
/>
<View className="flex-1">
<Text className="text-base font-semibold tracking-tight">{active.name}</Text>
<Text variant="muted" className="text-xs">{active.email}</Text>
</View>
<Badge variant="success" size="sm" icon={null}>Signed in</Badge>
</View>
<View className="mx-4 h-px bg-border" />
{/* Alternates — plain rows with a radio check, sheet-style. */}
{others.map((account, i) => (
<Press
key={account.id}
haptic="light"
pressedScale={0.98}
onPress={() => onSwitch?.(account)}
accessibilityRole="button"
accessibilityLabel={`Switch to ${account.name}, ${account.email}`}
className="flex-row items-center gap-3.5 rounded-2xl px-4 py-3"
>
<Mount delay={120 + i * 60}>
<View className="flex-row items-center gap-3.5">
<View>
<Avatar
size="lg"
source={account.avatarUrl ? { uri: account.avatarUrl } : undefined}
fallback={account.name}
alt={account.name}
/>
{/* Unread dot on the first alternate — switchers show
pending activity per account. */}
{i === 0 && (
<View className="absolute -right-0.5 -top-0.5 h-3.5 w-3.5 rounded-full border-2 border-card bg-destructive" />
)}
</View>
</View>
</Mount>
<View className="flex-1">
<Text className="text-base font-medium">{account.name}</Text>
<Text variant="muted" className="text-xs">
{account.meta ? `${account.email} · ${account.meta}` : account.email}
</Text>
</View>
<View
className="h-5 w-5 rounded-full border-2 border-foreground/20"
accessibilityElementsHidden
importantForAccessibility="no"
/>
</Press>
))}
{/* Utilities — icon row, still inside the sheet. */}
<View className="mt-1 flex-row gap-2 px-1">
<Press
haptic="light"
pressedScale={0.97}
onPress={() => onAddAccount?.()}
accessibilityRole="button"
accessibilityLabel="Add another account"
className="flex-1 flex-row items-center justify-center gap-2 rounded-2xl bg-[#EDF8C4] py-3.5"
>
<Icon name="user-plus" size="sm" color="#65A30D" />
<Text className="text-sm font-medium text-[#65A30D]">Add account</Text>
</Press>
<Press
haptic="light"
pressedScale={0.97}
onPress={() => onManageAccounts?.()}
accessibilityRole="button"
accessibilityLabel="Manage accounts"
className="flex-1 flex-row items-center justify-center gap-2 overflow-hidden rounded-2xl py-3.5"
>
<Glass interactive effect="clear" sheen={0.45} className="absolute inset-0 rounded-2xl" pointerEvents="none" />
<Icon name="settings" size="sm" color="mutedForeground" />
<Text className="text-sm font-medium">Manage</Text>
</Press>
</View>
<Press
haptic="light"
pressedScale={0.97}
onPress={() => {
haptics.warning();
onSignOutAll?.();
}}
accessibilityRole="button"
accessibilityLabel="Sign out of all accounts"
accessibilityHint="Ends every session on this device"
className="mt-1 min-h-11 items-center justify-center"
>
<Text className="text-sm font-medium text-destructive">
Sign out of all accounts
</Text>
</Press>
</View>
</Mount>
</View>
</World>
);
}
AccountSwitcherScreen.displayName = "AccountSwitcherScreen";
Recreate the "Account Switcher Screen" screen in React Native using LumioUI.
Goal: iOS account picker — pinned current account with badge, tappable alternate profiles, add/manage rows, sign-out-all footer Match the reference implementation's layout, behavior, and motion exactly — this is a production screen, not a mockup.
## Setup
Fastest path — install the screen plus its component deps:
```bash
npx lumioui add auth-account-switcher
```
Or pull just the components: `npx lumioui add avatar badge icon list text`
Native packages:
- @lumioui/ui@latest
- @lumioui/core@latest
- react-native-safe-area-context@*
- react-native-reanimated@*
- react-native-svg@*
- nativewind@*
Stack assumptions: Expo SDK 54 / RN 0.81, Reanimated 4, NativeWind v4, TypeScript strict, Expo Go compatible.
## Components — how, where, when
### `avatar` — Circular user image with fallback initials and status dot.
- `<Avatar>` ×3
### `badge` — Compact status pill with variants, icons and pulse option.
- `<Badge>` ×1
### `icon` — Lucide icon set mapped to theme colors and sizes.
- `<Icon>` ×2
### `list` — Settings-style rows with separators and press feedback.
- imported but not rendered directly — likely used through a sibling or as a utility
### `text` — Themed type scale from caption to display.
- `<Text>` ×7
## Conventions (non-negotiable)
- Styling via NativeWind `className` with semantic tokens only: `bg-background`, `text-foreground`, `bg-card`, `text-card-foreground`, `bg-secondary`, `bg-muted`, `text-muted-foreground`, `bg-accent`, `bg-destructive`, `border-border`. Hard-coded colors only inside media overlays (`text-white`, `bg-black/N`).
- Motion via the token presets from `@lumioui/core` (`spring.press`, `spring.surface`, `spring.glide`, `timing.fast`, `timing.slow`, …) — no bespoke spring configs or durations.
- Gate transform motion behind `useReducedMotion()`; keep opacity/color feedback, drop travel and overshoot.
- Haptics via `useHaptic()`: `light` on press, `selection` on toggle/select, `warning` on invalid input. Never import expo-haptics directly.
- Press feedback via `usePressAnimation()` (~0.97 scale), not `activeOpacity` tricks.
- a11y: `accessibilityRole` + `accessibilityLabel` on every control, touch targets ≥44pt.
- Entrance choreography: staggered `Mount` wrappers (delay 0/60/120…) so regions land in sequence instead of popping in at once.
- Wrap the screen in `SafeAreaView`; keep dark mode working via the semantic tokens.
## Deliverable
One file: `screens/auth/account-switcher.tsx` exporting AccountSwitcherScreen. Preserve the props interface — the host app wires the callbacks (navigation, submit handlers) through them.
## Reference implementation
The LumioUI source for this screen — reproduce it faithfully:
```tsx
import * as React from "react";
import { View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { Avatar, Badge, Glass, Icon, Text } from "@lumioui/ui";
import { useHaptic, useTheme } from "@lumioui/core";
import { Mount, Press, World } from "../shared";
export interface SwitchableAccount {
id: string;
name: string;
email: string;
avatarUrl?: string;
/** Extra context — e.g. "3 unread" or a workspace role. */
meta?: string;
}
export interface AccountSwitcherScreenProps {
accounts?: SwitchableAccount[];
/** The account currently active — rendered as the pinned header row. */
activeAccountId?: string;
onSwitch?: (account: SwitchableAccount) => void;
onAddAccount?: () => void;
onManageAccounts?: () => void;
onSignOutAll?: () => void;
}
const face = (n: number) => `https://i.pravatar.cc/150?img=${n}`;
const DEFAULT_ACCOUNTS: SwitchableAccount[] = [
{ id: "personal", name: "Amara Chen", email: "amara.chen@lumio.dev", avatarUrl: face(9), meta: "Personal" },
{ id: "work", name: "Amara Chen", email: "amara@nordstandard.co", avatarUrl: face(47), meta: "Nord Standard · Admin" },
{ id: "studio", name: "Studio M", email: "hello@studiom.design", avatarUrl: face(68), meta: "Guest" },
];
/** Account switcher — the Gmail/Slack idiom: dimmed context behind a floating
* bottom sheet of stacked identity rows, not a full settings page. The active
* account reads as the header of the sheet; alternates select with a filled
* check; utilities close the sheet. */
export function AccountSwitcherScreen({
accounts = DEFAULT_ACCOUNTS,
activeAccountId = "personal",
onSwitch,
onAddAccount,
onManageAccounts,
onSignOutAll,
}: AccountSwitcherScreenProps) {
const haptics = useHaptic();
const insets = useSafeAreaInsets();
const { colors } = useTheme();
const active = accounts.find((a) => a.id === activeAccountId) ?? accounts[0];
const others = accounts.filter((a) => a.id !== active?.id);
if (!active) return null;
return (
<World primary="#65A30D">
<View className="flex-1 bg-[#0C0B14]">
{/* Ghost context — what the sheet floats over. */}
<View className="flex-1 px-6" style={{ paddingTop: insets.top + 20 }} accessibilityElementsHidden>
<View className="flex-row items-center gap-3 opacity-30">
<Avatar size="sm" fallback={active.name} />
<View className="h-3 w-24 rounded-full bg-white/20" />
</View>
<View className="mt-6 gap-3 opacity-20">
<View className="h-24 rounded-3xl bg-white/10" />
<View className="h-24 rounded-3xl bg-white/10" />
<View className="h-24 rounded-3xl bg-white/10" />
</View>
</View>
<View className="absolute inset-0 bg-black/45" />
{/* Floating sheet — rounded card pinned to the bottom edge. */}
<Mount delay={60} className="absolute inset-x-3 bottom-3">
<View
className="overflow-hidden rounded-[28px] px-2 pb-2 pt-3"
style={{ paddingBottom: insets.bottom + 8 }}
accessibilityRole="menu"
accessibilityLabel="Switch account"
>
<Glass interactive
effect="regular"
sheen={0.55}
tintColor={`${colors.card}E6`}
className="absolute inset-0 rounded-[28px]"
pointerEvents="none"
/>
<View className="mb-2 h-1 w-10 self-center rounded-full bg-foreground/15" />
{/* Active account — pinned header inside the sheet. */}
<View className="flex-row items-center gap-3.5 px-4 pb-3 pt-1">
<Avatar
size="lg"
source={active.avatarUrl ? { uri: active.avatarUrl } : undefined}
fallback={active.name}
alt={active.name}
/>
<View className="flex-1">
<Text className="text-base font-semibold tracking-tight">{active.name}</Text>
<Text variant="muted" className="text-xs">{active.email}</Text>
</View>
<Badge variant="success" size="sm" icon={null}>Signed in</Badge>
</View>
<View className="mx-4 h-px bg-border" />
{/* Alternates — plain rows with a radio check, sheet-style. */}
{others.map((account, i) => (
<Press
key={account.id}
haptic="light"
pressedScale={0.98}
onPress={() => onSwitch?.(account)}
accessibilityRole="button"
accessibilityLabel={`Switch to ${account.name}, ${account.email}`}
className="flex-row items-center gap-3.5 rounded-2xl px-4 py-3"
>
<Mount delay={120 + i * 60}>
<View className="flex-row items-center gap-3.5">
<View>
<Avatar
size="lg"
source={account.avatarUrl ? { uri: account.avatarUrl } : undefined}
fallback={account.name}
alt={account.name}
/>
{/* Unread dot on the first alternate — switchers show
pending activity per account. */}
{i === 0 && (
<View className="absolute -right-0.5 -top-0.5 h-3.5 w-3.5 rounded-full border-2 border-card bg-destructive" />
)}
</View>
</View>
</Mount>
<View className="flex-1">
<Text className="text-base font-medium">{account.name}</Text>
<Text variant="muted" className="text-xs">
{account.meta ? `${account.email} · ${account.meta}` : account.email}
</Text>
</View>
<View
className="h-5 w-5 rounded-full border-2 border-foreground/20"
accessibilityElementsHidden
importantForAccessibility="no"
/>
</Press>
))}
{/* Utilities — icon row, still inside the sheet. */}
<View className="mt-1 flex-row gap-2 px-1">
<Press
haptic="light"
pressedScale={0.97}
onPress={() => onAddAccount?.()}
accessibilityRole="button"
accessibilityLabel="Add another account"
className="flex-1 flex-row items-center justify-center gap-2 rounded-2xl bg-[#EDF8C4] py-3.5"
>
<Icon name="user-plus" size="sm" color="#65A30D" />
<Text className="text-sm font-medium text-[#65A30D]">Add account</Text>
</Press>
<Press
haptic="light"
pressedScale={0.97}
onPress={() => onManageAccounts?.()}
accessibilityRole="button"
accessibilityLabel="Manage accounts"
className="flex-1 flex-row items-center justify-center gap-2 overflow-hidden rounded-2xl py-3.5"
>
<Glass interactive effect="clear" sheen={0.45} className="absolute inset-0 rounded-2xl" pointerEvents="none" />
<Icon name="settings" size="sm" color="mutedForeground" />
<Text className="text-sm font-medium">Manage</Text>
</Press>
</View>
<Press
haptic="light"
pressedScale={0.97}
onPress={() => {
haptics.warning();
onSignOutAll?.();
}}
accessibilityRole="button"
accessibilityLabel="Sign out of all accounts"
accessibilityHint="Ends every session on this device"
className="mt-1 min-h-11 items-center justify-center"
>
<Text className="text-sm font-medium text-destructive">
Sign out of all accounts
</Text>
</Press>
</View>
</Mount>
</View>
</World>
);
}
AccountSwitcherScreen.displayName = "AccountSwitcherScreen";
```
Screen: https://lumioui.dev/screens/auth-account-switcher