All patterns

Magic Link Wait

A soft halo breathes behind the envelope while the emailed link is outstanding and the resend timer counts down.

authenticationcalmelegantautomatic · looping · starter · ~2.6s
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.

241 lines · react + motion only
import { useEffect, useState } from "react";
import { motion, useReducedMotion } from "motion/react";

/**
 * Vibary · Magic Link Wait
 *
 * A soft halo breathes behind the envelope for as long as the emailed
 * link is outstanding, while the resend timer counts itself down.
 *
 * Self-contained: depends only on `react` and `motion`. Works with zero
 * props; tune via `variant`, `address`, `resendSeconds`, `accent`.
 * Requires the automatic JSX runtime (default since React 17).
 */

export type MagicLinkWaitProps = {
  /** Visual character of the motion. */
  variant?: "subtle" | "default" | "playful";
  /** Address echoed back in the copy. */
  address?: string;
  /** Seconds before resending is allowed. */
  resendSeconds?: number;
  /** Halo and button color. */
  accent?: string;
  /** Fires when the timer reaches zero. */
  onResendReady?: () => void;
};

type VariantConfig = {
  /** Seconds for one full breath. */
  cycle: number;
  /** How far the halo grows at the top of a breath. */
  swell: number;
  /** Halo opacity at the bottom and top of a breath. */
  low: number;
  high: number;
};

// Quality rule: this is a wait, not an event. The loop is one slow
// easeInOut breath with no bounce and no second beat, and it drives
// scale and opacity only — a waiting indicator that ticks or jumps
// invents progress the product does not actually have. Nothing under it
// moves, and the countdown is text, so it only ever changes value.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
  // Barely perceptible. For a screen someone may sit on for a while.
  subtle: {
    cycle: 3.4,
    swell: 1.07,
    low: 0.28,
    high: 0.44,
  },
  // A visible breath at reading pace. The all-purpose setting.
  default: {
    cycle: 2.6,
    swell: 1.16,
    low: 0.3,
    high: 0.62,
  },
  // A wider swell so the wait has some presence on a large, empty page.
  playful: {
    cycle: 2.1,
    swell: 1.26,
    low: 0.26,
    high: 0.76,
  },
};

/** 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)`;

function clock(seconds: number) {
  const safe = Math.max(0, seconds);
  return `${Math.floor(safe / 60)}:${String(safe % 60).padStart(2, "0")}`;
}

export default function MagicLinkWait({
  variant = "default",
  address = "you@company.com",
  resendSeconds = 30,
  accent = "#5B5BD6",
  onResendReady,
}: MagicLinkWaitProps) {
  const [remaining, setRemaining] = useState(resendSeconds);
  const reduceMotion = useReducedMotion();
  const cfg = VARIANTS[variant];
  const ready = remaining <= 0;

  useEffect(() => {
    if (remaining <= 0) return;
    const timer = setTimeout(() => setRemaining((value) => value - 1), 1000);
    return () => clearTimeout(timer);
  }, [remaining]);

  useEffect(() => {
    if (ready) onResendReady?.();
  }, [ready, onResendReady]);

  // Reduced motion keeps the halo — it is what marks the screen as
  // unfinished — and simply stops it breathing.
  const breath = reduceMotion
    ? { scale: 1, opacity: (cfg.low + cfg.high) / 2 }
    : {
        scale: [1, cfg.swell, 1],
        opacity: [cfg.low, cfg.high, cfg.low],
      };

  const breathTransition = reduceMotion
    ? { duration: 0.3 }
    : {
        duration: cfg.cycle,
        repeat: Infinity,
        ease: "easeInOut" as const,
      };

  return (
    <div
      style={{
        width: 288,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 12,
        padding: "24px 22px 20px",
        borderRadius: 16,
        border: `1px solid ${tone(12)}`,
        background: tone(6),
        color: "inherit",
        textAlign: "center",
      }}
    >
      <div
        style={{
          position: "relative",
          display: "grid",
          placeItems: "center",
          width: 84,
          height: 84,
        }}
      >
        {/* Two halos on the same breath, the outer one softer and a beat
            behind, so the glow has depth instead of reading as a single
            expanding disc. */}
        {[
          { size: 78, delay: reduceMotion ? 0 : cfg.cycle * 0.18, fade: 0.5 },
          { size: 56, delay: 0, fade: 1 },
        ].map((halo) => (
          <motion.span
            key={halo.size}
            aria-hidden
            animate={breath}
            transition={{ ...breathTransition, delay: halo.delay }}
            style={{
              position: "absolute",
              width: halo.size,
              height: halo.size,
              borderRadius: "50%",
              background: `color-mix(in srgb, ${accent} ${
                18 * halo.fade
              }%, transparent)`,
            }}
          />
        ))}

        <span
          aria-hidden
          style={{
            position: "relative",
            display: "grid",
            placeItems: "center",
            width: 44,
            height: 44,
            borderRadius: 14,
            background: tone(9),
            color: accent,
          }}
        >
          <svg
            width="22"
            height="22"
            viewBox="0 0 24 24"
            fill="none"
            stroke="currentColor"
            strokeWidth="1.8"
            strokeLinecap="round"
            strokeLinejoin="round"
          >
            <rect x="2.6" y="5" width="18.8" height="14" rx="3" />
            <path d="M4 8 12 14 20 8" />
          </svg>
        </span>
      </div>

      <div style={{ fontSize: 15.5, fontWeight: 650 }}>Your link is on the way</div>
      <p
        style={{
          margin: 0,
          fontSize: 12.5,
          lineHeight: 1.55,
          opacity: 0.62,
        }}
      >
        Open the message we sent to{" "}
        <span style={{ fontWeight: 600, opacity: 0.9 }}>{address}</span> and
        you will land back here signed in.
      </p>

      <button
        type="button"
        disabled={!ready}
        onClick={() => setRemaining(resendSeconds)}
        style={{
          width: "100%",
          marginTop: 2,
          padding: "9px 14px",
          fontSize: 13,
          fontWeight: 600,
          fontFamily: "inherit",
          fontVariantNumeric: "tabular-nums",
          color: ready ? "#ffffff" : "inherit",
          background: ready ? accent : tone(8),
          border: ready ? "none" : `1px solid ${tone(14)}`,
          borderRadius: 9,
          opacity: ready ? 1 : 0.55,
          cursor: ready ? "pointer" : "default",
          // The unlock is a state change, not a move: the label is text
          // and stays exactly where it was.
          transition: "background-color 260ms ease-out, opacity 260ms ease-out",
        }}
      >
        {ready ? "Send another link" : `Resend in ${clock(remaining)}`}
      </button>

      <div style={{ fontSize: 11, opacity: 0.42 }}>
        Nothing to type. The link does the signing in.
      </div>
    </div>
  );
}

About this pattern

Passwordless sign-in leaves someone on a page with nothing to do, which is exactly the situation an idle screen has to handle gracefully. Two halos share one slow easeInOut breath, the outer softer and a beat behind, so the glow has depth rather than reading as a disc that keeps expanding — and the cycle never ticks or jumps, because a marker that pretends to advance invents progress the product does not have. The resend timer carries the only real information on the page, so it is set in tabular figures and simply changes value; when it reaches zero the button recolors in place instead of moving. Reduced motion keeps the halo and stops the breath.

Emailed sign-in link pendingPasswordless onboarding stepDevice approval pendingResend cooldown after a request

Where it shows up

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

  • 10:15
    Check your emailWe sent a sign-in link to nils@ridgeline.co
    Resend link
    Sign-in screen

    Quiet waiting state for a link that will do the signing in.

Related patterns