Sign-in Success Handoff
The panel recedes as the product assembles behind it and the avatar travels into the header.
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 { useEffect, useId, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Sign-in Success Handoff
*
* The last second of signing in. Rather than a spinner, a white flash
* and a new page, the sign-in surface recedes while the product builds
* behind it — and the user's own avatar travels out of the panel and
* into the header it will live in from now on. One continuous move from
* the door into the room.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals mix from the inherited text color; the panel above the app
* uses the CSS system colors so it stays opaque and correctly toned in
* both a light and a dark app.
* Works with zero props; tune via `variant`, `personName`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SigninSuccessHandoffProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Who just signed in. */
personName?: string;
/** Their initials — the avatar is a disc, never a photo. */
initials?: string;
/** Workspace they land in. */
workspace?: string;
/** Accent for the avatar and the app chrome. */
accent?: string;
/** Fires once the app frame has finished assembling. */
onEntered?: () => void;
};
type VariantConfig = {
/** Seconds the confirmation is held before the handoff begins. */
lead: number;
/** Travel of each app element as it arrives, in px. */
rise: number;
/** Gap between app elements arriving. */
stagger: number;
/** How far the panel recedes as it leaves. */
recede: number;
avatar: { type: "spring"; stiffness: number; damping: number };
build: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: the avatar keeps exactly the same size in both places, so
// the travel between them is pure position — initials never rescale. The
// only scale in the pattern belongs to the panel on its way out, three
// percent, on an ease. Springs sit above a 0.8 damping ratio.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// For products people sign into daily, where the handoff should be
// over before it is noticed.
subtle: {
lead: 0.35,
rise: 6,
stagger: 0.03,
recede: 0.99,
avatar: { type: "spring", stiffness: 520, damping: 42 },
build: { type: "spring", stiffness: 540, damping: 44 },
},
// The all-purpose setting: the room assembles at a readable pace.
default: {
lead: 0.55,
rise: 10,
stagger: 0.05,
recede: 0.97,
avatar: { type: "spring", stiffness: 420, damping: 38 },
build: { type: "spring", stiffness: 440, damping: 38 },
},
// A longer hold and a wider build, for a first sign-in of the day.
playful: {
lead: 0.8,
rise: 15,
stagger: 0.07,
recede: 0.95,
avatar: { type: "spring", stiffness: 340, damping: 33 },
build: { type: "spring", stiffness: 360, damping: 33 },
},
};
/** Theme-adaptive neutral: `currentColor` is the inherited text color, so
* mixing it with `transparent` yields a surface, border or fill that is
* correctly toned on a light page and on a dark one. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const AVATAR = 30;
const ROWS = [
{ title: "Q3 revenue summary", meta: "Updated 4 minutes ago" },
{ title: "Brand refresh brief", meta: "Shared with 9 people" },
{ title: "Hiring plan 2027", meta: "Draft" },
];
export default function SigninSuccessHandoff({
variant = "default",
personName = "Priya Raman",
initials = "PR",
workspace = "Northwind Studio",
accent = "#5B5BD6",
onEntered,
}: SigninSuccessHandoffProps) {
const [entered, setEntered] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Scoped so two copies of this component on one page cannot claim the
// same travelling element.
const uid = useId();
useEffect(() => {
const timer = setTimeout(() => setEntered(true), cfg.lead * 1000);
return () => clearTimeout(timer);
}, [cfg.lead]);
const avatarTransition = reduceMotion ? { duration: 0 } : cfg.avatar;
// One shared disc: mounted in the panel before the handoff and in the
// header after it, never in both at once, so it travels between them
// instead of one fading out while another fades in.
const avatar = (
<motion.span
layoutId={`${uid}-avatar`}
transition={avatarTransition}
style={{
display: "grid",
placeItems: "center",
width: AVATAR,
height: AVATAR,
borderRadius: 999,
background: accent,
color: "#FFFFFF",
fontSize: 11.5,
fontWeight: 700,
letterSpacing: 0.3,
}}
>
{initials}
</motion.span>
);
const build = (index: number) =>
reduceMotion
? {
animate: { opacity: entered ? 1 : 0 },
transition: { duration: 0.22, ease: "easeOut" as const },
}
: {
animate: {
opacity: entered ? 1 : 0,
y: entered ? 0 : cfg.rise,
},
transition: {
...cfg.build,
delay: entered ? index * cfg.stagger : 0,
opacity: {
duration: 0.26,
delay: entered ? index * cfg.stagger : 0,
ease: "easeOut" as const,
},
},
};
return (
<div
style={{
position: "relative",
width: 320,
height: 300,
borderRadius: 16,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
overflow: "hidden",
}}
>
{/* The product, assembling behind the panel. Nothing here waits for
the panel to finish leaving — the two halves of the move overlap,
which is what makes it read as one motion. */}
<div style={{ position: "absolute", inset: 0, display: "flex", flexDirection: "column" }}>
<div
style={{
display: "flex",
alignItems: "center",
gap: 9,
height: 46,
padding: "0 12px",
borderBottom: `1px solid ${tone(10)}`,
}}
>
<motion.span
initial={false}
{...build(0)}
style={{
display: "grid",
placeItems: "center",
width: 24,
height: 24,
borderRadius: 7,
background: tone(12),
fontSize: 10,
fontWeight: 700,
}}
>
NW
</motion.span>
<motion.span initial={false} {...build(1)} style={{ fontSize: 12.5, fontWeight: 650 }}>
{workspace}
</motion.span>
<span style={{ flex: 1 }} />
{/* The avatar's seat in the header. It is empty until the
handoff, then the disc arrives in it. */}
<span
style={{
display: "grid",
placeItems: "center",
width: AVATAR,
height: AVATAR,
}}
>
{entered && avatar}
</span>
</div>
<div style={{ display: "flex", flex: 1, minHeight: 0 }}>
<div
style={{
width: 50,
padding: "12px 0",
display: "flex",
flexDirection: "column",
alignItems: "center",
gap: 8,
borderRight: `1px solid ${tone(10)}`,
}}
>
{[0, 1, 2, 3].map((index) => (
<motion.span
key={index}
initial={false}
{...build(2 + index)}
style={{
width: 26,
height: 26,
borderRadius: 8,
background: index === 0 ? tone(16) : tone(9),
}}
/>
))}
</div>
<div style={{ flex: 1, minWidth: 0, padding: "12px 12px 0" }}>
<motion.div
initial={false}
{...build(3)}
style={{ fontSize: 11, fontWeight: 650, letterSpacing: 0.4, opacity: 0.5 }}
>
RECENT
</motion.div>
{ROWS.map((row, index) => (
<motion.div
key={row.title}
initial={false}
{...build(4 + index)}
onAnimationComplete={
index === ROWS.length - 1 && entered ? () => onEntered?.() : undefined
}
style={{
display: "flex",
alignItems: "center",
gap: 9,
padding: "9px 0",
borderBottom: `1px solid ${tone(8)}`,
}}
>
<span
aria-hidden
style={{
width: 22,
height: 22,
flexShrink: 0,
borderRadius: 6,
background: tone(10),
}}
/>
<span style={{ minWidth: 0 }}>
<span style={{ display: "block", fontSize: 12, fontWeight: 600 }}>
{row.title}
</span>
<span style={{ display: "block", fontSize: 10.5, opacity: 0.5, marginTop: 2 }}>
{row.meta}
</span>
</span>
</motion.div>
))}
</div>
</div>
</div>
{/* The panel does not slide away or shrink into a corner: it steps
back three percent and dissolves, which reads as the app coming
forward rather than the panel being dismissed. */}
<motion.div
aria-hidden={entered}
initial={false}
animate={{
opacity: entered ? 0 : 1,
scale: entered ? cfg.recede : 1,
y: entered ? -4 : 0,
}}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { duration: 0.34, ease: "easeIn" }
}
style={{
position: "absolute",
inset: 0,
display: "grid",
placeItems: "center",
padding: 22,
pointerEvents: entered ? "none" : "auto",
}}
>
<div
style={{
width: "100%",
padding: 16,
borderRadius: 14,
// Opaque on purpose: the panel sits over the app, and a
// translucent one would leave the product legible through
// the confirmation. `Canvas`/`CanvasText` are the CSS system
// colors for page background and page text.
background: "Canvas",
color: "CanvasText",
border: `1px solid ${tone(12)}`,
boxShadow: "0 18px 44px rgba(0,0,0,0.22)",
textAlign: "center",
}}
>
<span
style={{
display: "grid",
placeItems: "center",
width: AVATAR,
height: AVATAR,
margin: "0 auto",
}}
>
{!entered && avatar}
</span>
<div style={{ fontSize: 14, fontWeight: 650, marginTop: 11 }}>
Welcome back, {personName.split(" ")[0]}
</div>
<div style={{ fontSize: 12, opacity: 0.6, marginTop: 4 }}>
Opening {workspace}
</div>
<div
style={{
height: 3,
marginTop: 14,
borderRadius: 999,
background: tone(10),
overflow: "hidden",
}}
>
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{
duration: reduceMotion ? 0.2 : cfg.lead + 0.2,
ease: "easeInOut",
}}
style={{
height: "100%",
borderRadius: 999,
background: accent,
transformOrigin: "left center",
}}
/>
</div>
</div>
</motion.div>
</div>
);
}About this pattern
The last second of signing in is usually a spinner, a white flash and a new page. Here the two halves overlap instead: the confirmation steps back three percent and dissolves while the workspace behind it assembles from the top bar down, so it reads as the product coming forward rather than a panel being dismissed. The user avatar is the thread between the two states — one disc, mounted in the panel before the handoff and in the header after it, never in both at once, so it travels rather than crossfading. It keeps exactly the same size in both places, which means the initials never rescale during the move. The only scale in the whole pattern belongs to the panel on its way out.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
Signing in resolves straight into the product instead of a loading page.
Related patterns
- Sign-in Form EntranceHeading, fields and button rise into place in one quick sequence as the screen opens.
- Account SwitchThe chosen avatar travels up into the header slot while the one it replaces goes back down into the list.
- Session Lock BlurAn idle workspace softens behind a scrim while a lock card rises over it, keeping shape but not content.