All patterns

Account Switch

The chosen avatar travels up into the header slot while the one it replaces goes back down into the list.

authenticationpremiumelegantinteraction · finite · advanced · ~0.4s
Interactive · click to play
Variant

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.

316 lines · react + motion only
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.

Multi-account switcherPersonal and work profile toggleOrganisation switcher in a headerSupport agent switching seats

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Sign inUse the address your team invited
    Email
    nils@ridgeline.co
    Password
    ••••••••••
    Continue
    Sign-in screen

    A list of signed-in identities where the header always shows the current one.

Related patterns