SSO Redirect Handoff
Both marks stay on screen while a connector draws between them and a token crosses it.
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, useState, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · SSO Redirect Handoff
*
* The seconds between leaving an app and arriving at an identity
* provider are usually a blank screen. This makes the handoff legible:
* both marks are on screen, a connector draws between them, and a token
* travels across it while the redirect happens.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Neutrals mix from the inherited text color, so the panel reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `provider`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SsoRedirectHandoffProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** App the user is leaving. */
appName?: string;
/** Identity provider the user is being sent to. */
provider?: string;
/** Primary color of the app mark and the connector. */
accent?: string;
/** Fires once the handoff sequence has finished. */
onComplete?: () => void;
};
type VariantConfig = {
/** Seconds before the connector starts drawing. */
lead: number;
/** How long the connector takes to reach the provider. */
draw: number;
/** How long the whole handoff reads as taking. */
total: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Quality rule: nothing here overshoots. A handoff screen is a promise
// that something is happening on a server, and springy marks make that
// promise look like decoration. Every spring sits above a 0.8 damping
// ratio; variants change pace only.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Brisk — for providers that resolve fast enough that a long sequence
// would be a lie.
subtle: {
lead: 0.16,
draw: 0.42,
total: 1.2,
spring: { type: "spring", stiffness: 520, damping: 42 },
},
// The all-purpose setting: long enough to be read, short enough that
// the redirect usually beats it.
default: {
lead: 0.24,
draw: 0.58,
total: 1.7,
spring: { type: "spring", stiffness: 420, damping: 38 },
},
// A deliberate, ceremonial handoff for enterprise sign-in.
playful: {
lead: 0.3,
draw: 0.74,
total: 2.2,
spring: { type: "spring", stiffness: 340, damping: 34 },
},
};
/** 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 TRACK = 92;
export default function SsoRedirectHandoff({
variant = "default",
appName = "Meridian",
provider = "Atlas ID",
accent = "#5B5BD6",
onComplete,
}: SsoRedirectHandoffProps) {
const [handedOff, setHandedOff] = useState(false);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const arriveAt = cfg.lead + cfg.draw;
// Under reduced motion there is no journey to wait for, so the arrived
// state is derived rather than scheduled.
const arrived = Boolean(reduceMotion) || handedOff;
useEffect(() => {
if (reduceMotion) {
onComplete?.();
return;
}
const swap = setTimeout(() => setHandedOff(true), arriveAt * 1000);
const done = setTimeout(() => onComplete?.(), cfg.total * 1000);
return () => {
clearTimeout(swap);
clearTimeout(done);
};
}, [arriveAt, cfg.total, onComplete, reduceMotion]);
const markEnter = (delay: number) =>
reduceMotion
? { initial: { opacity: 0 }, animate: { opacity: 1 }, transition: { duration: 0.15 } }
: {
initial: { opacity: 0, y: 8 },
animate: { opacity: 1, y: 0 },
transition: { ...cfg.spring, delay },
};
return (
<div
style={{
width: 316,
padding: "26px 20px 20px",
borderRadius: 18,
background: tone(6),
color: "inherit",
border: `1px solid ${tone(12)}`,
textAlign: "center",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 0,
}}
>
<motion.div {...markEnter(0)}>
<Mark label={appName} accent={accent} filled>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M4 14.5V7.2l6-3.4 6 3.4v7.3"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M8 14.5v-3.8h4v3.8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Mark>
</motion.div>
{/* The connector is the whole point: a line that has to be drawn
before the second mark means anything. The token rides the
same track a few frames behind the stroke. */}
<div
style={{
position: "relative",
width: TRACK,
height: 24,
display: "grid",
placeItems: "center",
}}
>
<svg
width={TRACK}
height="10"
viewBox={`0 0 ${TRACK} 10`}
fill="none"
aria-hidden
style={{ position: "absolute", inset: "7px 0 auto 0" }}
>
<path
d={`M2 5H${TRACK - 2}`}
stroke={tone(14)}
strokeWidth="1.6"
strokeLinecap="round"
strokeDasharray="3 5"
/>
<motion.path
d={`M2 5H${TRACK - 2}`}
stroke={accent}
strokeWidth="1.8"
strokeLinecap="round"
initial={{ pathLength: reduceMotion ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={
reduceMotion
? { duration: 0 }
: { duration: cfg.draw, delay: cfg.lead, ease: "easeInOut" }
}
/>
</svg>
{!reduceMotion && (
<motion.span
aria-hidden
initial={{ x: -TRACK / 2 + 4, opacity: 0 }}
animate={{ x: TRACK / 2 - 4, opacity: [0, 1, 1, 0] }}
transition={{
duration: cfg.draw,
delay: cfg.lead + 0.06,
ease: "easeInOut",
opacity: { duration: cfg.draw, times: [0, 0.12, 0.8, 1] },
}}
style={{
position: "absolute",
width: 7,
height: 7,
borderRadius: 999,
background: accent,
boxShadow: `0 0 0 3px ${tone(8)}`,
}}
/>
)}
</div>
<motion.div {...markEnter(reduceMotion ? 0 : 0.08)}>
<Mark label={provider} accent={accent} active={arrived}>
<svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path
d="M10 3.2l5 1.9v4.3c0 3-2.1 5.4-5 6.4-2.9-1-5-3.4-5-6.4V5.1l5-1.9z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path
d="M7.8 9.9l1.7 1.7 3-3.4"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Mark>
</motion.div>
</div>
{/* The caption swaps rather than rewrites: the line is short and
the two states have the same weight, so a crossfade with a few
pixels of lift reads cleanly at this size. */}
<div style={{ position: "relative", height: 20, marginTop: 20 }}>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={arrived ? "arrived" : "leaving"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: 5 }}
animate={{ opacity: 1, y: 0 }}
exit={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -5 }}
transition={{ duration: 0.2, ease: "easeOut" }}
style={{
position: "absolute",
inset: 0,
fontSize: 13.5,
fontWeight: 600,
}}
>
{arrived ? `Handing off to ${provider}` : "Verifying your organisation"}
</motion.div>
</AnimatePresence>
</div>
<div style={{ fontSize: 12, opacity: 0.5, marginTop: 6 }}>
Keep this window open until sign-in finishes
</div>
{/* A hairline that fills for the life of the redirect. scaleX from
a left origin, so it costs one transform and never reflows. */}
<div
style={{
height: 3,
marginTop: 16,
borderRadius: 999,
background: tone(10),
overflow: "hidden",
}}
>
<motion.div
initial={{ scaleX: 0 }}
animate={{ scaleX: 1 }}
transition={{ duration: reduceMotion ? 0.4 : cfg.total, ease: "easeInOut" }}
style={{
height: "100%",
borderRadius: 999,
background: accent,
transformOrigin: "left center",
}}
/>
</div>
</div>
);
}
function Mark({
label,
accent,
children,
filled = false,
active = false,
}: {
label: string;
accent: string;
children: ReactNode;
filled?: boolean;
active?: boolean;
}) {
return (
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", gap: 8 }}>
<div
aria-hidden
style={{
display: "grid",
placeItems: "center",
width: 46,
height: 46,
borderRadius: 14,
background: filled ? accent : tone(8),
color: filled ? "#FFFFFF" : "inherit",
border: filled ? "none" : `1px solid ${active ? accent : tone(14)}`,
boxShadow: active && !filled ? `0 0 0 3px ${tone(8)}` : "none",
transition: "border-color 220ms ease, box-shadow 220ms ease",
}}
>
{children}
</div>
<div style={{ fontSize: 11.5, fontWeight: 600, opacity: 0.7 }}>{label}</div>
</div>
);
}About this pattern
The gap between leaving an app and landing on an identity provider is usually a white screen with a spinner, which tells the user nothing about where they are going. Here both marks are present from the first frame and a connector draws from one to the other, with a small token riding the same track a few frames behind the stroke — the redirect becomes a journey with a visible destination. Nothing overshoots: a handoff is a promise that a server is working, and springy marks make that promise look decorative. A hairline fills for the life of the redirect so a slow provider still reads as progress rather than a hang.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Sign-in screen
An interstitial that names the destination provider while the redirect resolves.
Related patterns
- Biometric PromptA fingerprint glyph strokes itself in while the device reads, then a ring settles once it accepts.
- Passkey CreateA key glyph strokes itself into existence, bow first, as the credential registers.
- Sign-in Success HandoffThe panel recedes as the product assembles behind it and the avatar travels into the header.