Account Switch
The chosen avatar travels up into the header slot while the one it replaces goes back down into the list.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Account Switch
*
* Choosing another account sends its avatar up into the header slot
* while the one it replaces travels back down into the list.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `accounts`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SwitchableAccount = {
/** Stable key — also the shared-layout identity of the avatar. */
id: string;
/** Two letters shown in the avatar. */
initials: string;
name: string;
/** Organisation or role line. */
org: string;
/** Avatar fill. */
tint: string;
};
export type AccountSwitchProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Accounts offered, in list order. */
accounts?: SwitchableAccount[];
/** Fires with the id of the account now in the header. */
onSwitch?: (id: string) => void;
};
type VariantConfig = {
/** How the avatars travel between the header and the list. */
move: { type: "spring"; stiffness: number; damping: number };
/** Travel of the crossfading header text, in px. */
textTravel: number;
/** Seconds the header text takes to swap. */
textFade: number;
};
// Quality rule: the avatars are the only things that move, and they move
// between two slots of identical size, so the shared-layout transition
// is a pure translate — the initials inside them are text and are never
// rescaled. Springs sit above a 0.8 damping ratio: an avatar that
// overshoots its slot has landed on the wrong account for a frame.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short hop. For account menus used constantly by support and admin
// staff.
subtle: {
move: { type: "spring", stiffness: 750, damping: 53 },
textTravel: 2,
textFade: 0.11,
},
// The travel is legible: you can see which row became the header. The
// all-purpose setting.
default: {
move: { type: "spring", stiffness: 460, damping: 40 },
textTravel: 7,
textFade: 0.18,
},
// Slower and more deliberate, so the trade between the two avatars is
// unmistakable.
playful: {
move: { type: "spring", stiffness: 280, damping: 31 },
textTravel: 14,
textFade: 0.25,
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color —
* near-black on a light page, near-white on a dark one — so mixing it
* with `transparent` yields a surface or border correctly toned in
* either theme. Nothing to configure. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const AVATAR = 30;
const DEFAULT_ACCOUNTS: SwitchableAccount[] = [
{
id: "priya",
initials: "PR",
name: "Priya Raman",
org: "Meridian · Design",
tint: "#5B5BD6",
},
{
id: "noor",
initials: "NS",
name: "Noor Salim",
org: "Northwind · Operations",
tint: "#2E9E6B",
},
{
id: "tomas",
initials: "TK",
name: "Tomas Kron",
org: "Meridian · Personal",
tint: "#C77A2E",
},
];
function Avatar({ account }: { account: SwitchableAccount }) {
return (
<span
style={{
display: "grid",
placeItems: "center",
width: AVATAR,
height: AVATAR,
borderRadius: "50%",
background: account.tint,
color: "#ffffff",
fontSize: 11.5,
fontWeight: 700,
letterSpacing: 0.3,
}}
>
{account.initials}
</span>
);
}
export default function AccountSwitch({
variant = "default",
accounts = DEFAULT_ACCOUNTS,
onSwitch,
}: AccountSwitchProps) {
const [activeId, setActiveId] = useState(accounts[0]?.id ?? "");
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const active = accounts.find((account) => account.id === activeId) ?? accounts[0];
// Reduced motion keeps one DOM tree and one shared identity per
// avatar; only the travel between slots is switched off.
const moveTransition = reduceMotion ? { duration: 0 } : cfg.move;
return (
<div
style={{
width: 296,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(6),
color: "inherit",
overflow: "hidden",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
gap: 11,
padding: "14px 16px",
borderBottom: `1px solid ${tone(10)}`,
}}
>
{/* Each avatar owns a shared identity keyed by account. When the
selection changes, the incoming one is already mounted down
in its row and the outgoing one is already up here, so the
two simply trade slots — no clone, no fade, no duplicate. */}
{active && (
<motion.span
key={`header-${active.id}`}
layoutId={`vibary-acct-${active.id}`}
transition={moveTransition}
style={{ flex: "0 0 auto" }}
>
<Avatar account={active} />
</motion.span>
)}
<div style={{ position: "relative", height: 34, flex: 1 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={active?.id}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, y: cfg.textTravel }
}
animate={{ opacity: 1, y: 0 }}
exit={{
opacity: 0,
y: reduceMotion ? 0 : -cfg.textTravel * 0.6,
transition: { duration: cfg.textFade * 0.7, ease: "easeIn" },
}}
transition={{ duration: cfg.textFade, ease: "easeOut" }}
style={{ position: "absolute", inset: 0 }}
>
<div style={{ fontSize: 10.5, opacity: 0.45, letterSpacing: 0.3 }}>
SIGNED IN AS
</div>
<div style={{ fontSize: 13, fontWeight: 650, marginTop: 1 }}>
{active?.name}
</div>
</motion.div>
</AnimatePresence>
</div>
<svg
aria-hidden
width="14"
height="14"
viewBox="0 0 16 16"
fill="none"
style={{ opacity: 0.4 }}
>
<path
d="M4.5 6.5 8 10l3.5-3.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</div>
<div style={{ padding: "10px 10px 12px" }}>
<div
style={{
padding: "2px 6px 8px",
fontSize: 10.5,
fontWeight: 600,
letterSpacing: 0.3,
opacity: 0.45,
}}
>
SWITCH TO
</div>
{accounts.map((account) => {
const isActive = account.id === activeId;
return (
<button
key={account.id}
type="button"
onClick={() => {
if (isActive) return;
setActiveId(account.id);
onSwitch?.(account.id);
}}
style={{
display: "flex",
alignItems: "center",
gap: 11,
width: "100%",
padding: "8px 6px",
fontFamily: "inherit",
color: "inherit",
textAlign: "left",
background: "transparent",
border: "none",
borderRadius: 10,
cursor: isActive ? "default" : "pointer",
}}
>
{/* The row of whichever account is in the header keeps an
invisible avatar of the same size. It reserves the slot
the travelling avatar will come home to, so no row ever
changes height mid-switch. */}
{isActive ? (
<span
aria-hidden
style={{
width: AVATAR,
height: AVATAR,
flex: "0 0 auto",
borderRadius: "50%",
border: `1.5px dashed ${tone(18)}`,
boxSizing: "border-box",
}}
/>
) : (
<motion.span
layoutId={`vibary-acct-${account.id}`}
transition={moveTransition}
style={{ flex: "0 0 auto" }}
>
<Avatar account={account} />
</motion.span>
)}
<span style={{ flex: 1 }}>
<span
style={{ display: "block", fontSize: 12.5, fontWeight: 600 }}
>
{account.name}
</span>
<span style={{ display: "block", fontSize: 11, opacity: 0.5 }}>
{account.org}
</span>
</span>
<span
style={{
fontSize: 10.5,
fontWeight: 600,
letterSpacing: 0.2,
opacity: isActive ? 0.5 : 0,
transition: "opacity 200ms ease-out",
}}
>
CURRENT
</span>
</button>
);
})}
</div>
</div>
);
}About this pattern
A switcher that answers the only question people have after they use it: which account am I in now. Each avatar owns a shared identity, so the incoming one is already mounted in its row and the outgoing one is already in the header — they simply trade slots, with no cloned copy and no crossfade between two faces. Both slots are exactly the same size, which makes the move a pure translate and keeps the initials from ever being rescaled. The row that gives up its avatar keeps a same-size outline in its place so nothing changes height mid-switch, and the header name crossfades in a fixed slot at constant type size.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
A list of signed-in identities where the header always shows the current one.
Related patterns
- Avatar Upload CropA picked image drops into the circular frame and settles oversized, ready to be nudged.
- Sign-in Success HandoffThe panel recedes as the product assembles behind it and the avatar travels into the header.
- Logout ConfirmThe workspace dims and settles back while the sign-out question rises over it and takes focus.