All patterns

SSO Redirect Handoff

Both marks stay on screen while a connector draws between them and a token crosses it.

authenticationpremiumfuturisticautomatic · finite · intermediate · ~1.7s
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.

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

Enterprise SSO sign-inOAuth provider redirectIdentity provider interstitialThird-party authorisation handoff

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

    An interstitial that names the destination provider while the redirect resolves.

Related patterns