Fazekit

Code

"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, ArrowRight, Check, Loader2, Mail, RotateCw } from "lucide-react";
import { cn } from "@/lib/utils";

type Step = "email" | "sent" | "code" | "success";

export interface AuthMagicLinkProps {
  brand?: string;
  /** Called when the user asks for a link/code. Throw to show an error. */
  onSendLink?: (email: string) => Promise<void>;
  /** Return true when the code is valid. Demo default accepts 123456. */
  onVerify?: (email: string, code: string) => Promise<boolean>;
  /** Seconds before "Resend" becomes available. */
  resendAfter?: number;
  codeLength?: number;
  /** Show the demo code hint under the OTP boxes. */
  showDemoHint?: boolean;
  className?: string;
}

const emailOk = (v: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(v.trim());
const wait = (ms: number) => new Promise((r) => setTimeout(r, ms));

export function AuthMagicLink({
  brand = "Orbit",
  onSendLink,
  onVerify,
  resendAfter = 30,
  codeLength = 6,
  showDemoHint = true,
  className,
}: AuthMagicLinkProps) {
  const reduce = useReducedMotion();
  const uid = React.useId();
  const [step, setStep] = React.useState<Step>("email");
  const [email, setEmail] = React.useState("");
  const [emailError, setEmailError] = React.useState("");
  const [sending, setSending] = React.useState(false);
  const [digits, setDigits] = React.useState<string[]>(() => Array(codeLength).fill(""));
  const [codeError, setCodeError] = React.useState("");
  const [verifying, setVerifying] = React.useState(false);
  const [shake, setShake] = React.useState(0);
  const [countdown, setCountdown] = React.useState(0);
  const [announce, setAnnounce] = React.useState("");
  const inputs = React.useRef<Array<HTMLInputElement | null>>([]);

  React.useEffect(() => {
    if (countdown <= 0) return;
    const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
    return () => clearTimeout(t);
  }, [countdown]);

  const send = async (e?: React.FormEvent) => {
    e?.preventDefault();
    if (!emailOk(email)) {
      setEmailError(email.trim() ? "That doesn't look like a valid email." : "Enter your email to continue.");
      return;
    }
    setEmailError("");
    setSending(true);
    try {
      if (onSendLink) await onSendLink(email.trim());
      else await wait(1000);
      setStep("sent");
      setCountdown(resendAfter);
      setAnnounce(`We sent a sign-in link to ${email.trim()}.`);
    } catch (err) {
      setEmailError(err instanceof Error ? err.message : "Couldn't send the link. Try again.");
    } finally {
      setSending(false);
    }
  };

  const resend = async () => {
    if (countdown > 0) return;
    setCountdown(resendAfter);
    setDigits(Array(codeLength).fill(""));
    setCodeError("");
    if (onSendLink) await onSendLink(email.trim());
    setAnnounce("A new code is on its way.");
  };

  const verify = async (code: string) => {
    setVerifying(true);
    setCodeError("");
    const ok = onVerify ? await onVerify(email.trim(), code) : (await wait(900), code === "123456");
    setVerifying(false);
    if (ok) {
      setStep("success");
      setAnnounce("Code verified. You're signed in.");
    } else {
      setCodeError("That code didn't match. Check the email and try again.");
      setShake((s) => s + 1);
      setDigits(Array(codeLength).fill(""));
      setTimeout(() => inputs.current[0]?.focus(), 30);
    }
  };

  const fill = (start: number, chars: string) => {
    const clean = chars.replace(/\D/g, "").slice(0, codeLength - start);
    if (!clean) return;
    const next = [...digits];
    clean.split("").forEach((c, i) => (next[start + i] = c));
    setDigits(next);
    setCodeError("");
    const focusAt = Math.min(codeLength - 1, start + clean.length);
    inputs.current[focusAt]?.focus();
    if (next.every(Boolean)) verify(next.join(""));
  };

  const onKey = (i: number, e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Backspace") {
      e.preventDefault();
      const next = [...digits];
      if (next[i]) next[i] = "";
      else if (i > 0) {
        next[i - 1] = "";
        inputs.current[i - 1]?.focus();
      }
      setDigits(next);
    } else if (e.key === "ArrowLeft" && i > 0) {
      e.preventDefault();
      inputs.current[i - 1]?.focus();
    } else if (e.key === "ArrowRight" && i < codeLength - 1) {
      e.preventDefault();
      inputs.current[i + 1]?.focus();
    }
  };

  const reset = () => {
    setStep("email");
    setDigits(Array(codeLength).fill(""));
    setCodeError("");
  };

  const slide = reduce
    ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } }
    : {
        initial: { opacity: 0, x: 24, filter: "blur(4px)" },
        animate: { opacity: 1, x: 0, filter: "blur(0px)" },
        exit: { opacity: 0, x: -24, filter: "blur(4px)" },
      };

  const mm = String(Math.floor(countdown / 60)).padStart(1, "0");
  const ss = String(countdown % 60).padStart(2, "0");

  return (
    <section className={cn("relative flex min-h-[720px] w-full items-center justify-center overflow-hidden bg-background px-4 py-12 text-foreground", className)}>
      {/* backdrop */}
      <div aria-hidden className="pointer-events-none absolute inset-0">
        <div className="absolute left-1/2 top-[-20%] h-[520px] w-[820px] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--primary)_22%,transparent),transparent)]" />
        <div
          className="absolute inset-0 opacity-60 dark:opacity-40"
          style={{
            backgroundImage: "radial-gradient(color-mix(in oklab, var(--foreground) 14%, transparent) 1px, transparent 1px)",
            backgroundSize: "22px 22px",
            maskImage: "radial-gradient(ellipse at center, black 30%, transparent 70%)",
            WebkitMaskImage: "radial-gradient(ellipse at center, black 30%, transparent 70%)",
          }}
        />
      </div>

      <p className="sr-only" aria-live="polite">
        {announce}
      </p>

      <div className="relative w-full max-w-[420px]">
        <div className="mb-6 flex items-center justify-center gap-2">
          <span className="grid size-8 place-items-center rounded-lg bg-foreground text-background">
            <svg viewBox="0 0 24 24" className="size-4" aria-hidden>
              <circle cx="12" cy="12" r="4" fill="currentColor" />
              <ellipse cx="12" cy="12" rx="10" ry="4.5" fill="none" stroke="currentColor" strokeWidth="1.8" transform="rotate(-25 12 12)" />
            </svg>
          </span>
          <span className="font-semibold tracking-tight">{brand}</span>
        </div>

        <div className="overflow-hidden rounded-2xl border bg-card/90 text-card-foreground shadow-[0_1px_2px_rgba(0,0,0,.04),0_20px_50px_-20px_rgba(0,0,0,.25)] backdrop-blur-xl">
          <div className="h-1 bg-muted">
            <motion.div
              className="h-full bg-primary"
              initial={false}
              animate={{ width: `${({ email: 25, sent: 50, code: 75, success: 100 } as const)[step]}%` }}
              transition={{ type: "spring", stiffness: 200, damping: 30 }}
            />
          </div>
          <div className="p-6 sm:p-8">
            <AnimatePresence mode="wait" initial={false}>
              {step === "email" && (
                <motion.div key="email" {...slide} transition={{ duration: 0.25 }}>
                  <h1 className="text-xl font-semibold tracking-tight">Sign in without a password</h1>
                  <p className="mt-1.5 text-sm text-muted-foreground">We&apos;ll email you a magic link and a one-time code.</p>
                  <form noValidate onSubmit={send} className="mt-6 space-y-3">
                    <label htmlFor={`${uid}-email`} className="text-sm font-medium">
                      Email address
                    </label>
                    <div className="relative">
                      <Mail className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
                      <input
                        id={`${uid}-email`}
                        type="email"
                        inputMode="email"
                        autoComplete="email"
                        autoFocus
                        value={email}
                        onChange={(e) => {
                          setEmail(e.target.value);
                          setEmailError("");
                        }}
                        aria-invalid={!!emailError}
                        aria-describedby={emailError ? `${uid}-email-err` : undefined}
                        placeholder="[email protected]"
                        className={cn(
                          "h-11 w-full rounded-lg border bg-background pl-10 pr-3.5 text-sm shadow-xs outline-none transition placeholder:text-muted-foreground/70 focus-visible:border-ring focus-visible:ring-4 focus-visible:ring-ring/15",
                          emailError && "border-destructive focus-visible:border-destructive focus-visible:ring-destructive/15",
                        )}
                      />
                    </div>
                    {emailError && (
                      <p id={`${uid}-email-err`} role="alert" className="text-xs font-medium text-destructive">
                        {emailError}
                      </p>
                    )}
                    <button
                      type="submit"
                      disabled={sending}
                      className="group inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground shadow-sm transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card disabled:opacity-80"
                    >
                      {sending ? <Loader2 className="size-4 animate-spin" /> : null}
                      {sending ? "Sending link…" : "Email me a sign-in link"}
                      {!sending && <ArrowRight className="size-4 transition-transform group-hover:translate-x-0.5" />}
                    </button>
                  </form>
                  <p className="mt-6 text-center text-xs text-muted-foreground">
                    By continuing you agree to the <a href="#terms" className="underline underline-offset-4 hover:text-foreground">Terms</a> and{" "}
                    <a href="#privacy" className="underline underline-offset-4 hover:text-foreground">Privacy Policy</a>.
                  </p>
                </motion.div>
              )}

              {step === "sent" && (
                <motion.div key="sent" {...slide} transition={{ duration: 0.25 }} className="text-center">
                  <Envelope reduce={!!reduce} />
                  <h1 className="mt-6 text-xl font-semibold tracking-tight">Check your inbox</h1>
                  <p className="mt-1.5 text-sm text-muted-foreground">
                    We sent a sign-in link to <span className="font-medium text-foreground">{email.trim()}</span>. It expires in 15 minutes.
                  </p>
                  <button
                    type="button"
                    onClick={() => setStep("code")}
                    className="mt-6 inline-flex h-11 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground shadow-sm transition hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
                  >
                    Enter the {codeLength}-digit code instead
                  </button>
                  <div className="mt-3 flex items-center justify-between text-sm">
                    <button type="button" onClick={reset} className="inline-flex items-center gap-1.5 rounded px-1 text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                      <ArrowLeft className="size-3.5" /> Use another email
                    </button>
                    <ResendButton countdown={countdown} label={`${mm}:${ss}`} onResend={resend} />
                  </div>
                </motion.div>
              )}

              {step === "code" && (
                <motion.div key="code" {...slide} transition={{ duration: 0.25 }}>
                  <button type="button" onClick={() => setStep("sent")} className="mb-4 inline-flex items-center gap-1.5 rounded text-sm text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
                    <ArrowLeft className="size-3.5" /> Back
                  </button>
                  <h1 className="text-xl font-semibold tracking-tight">Enter verification code</h1>
                  <p className="mt-1.5 text-sm text-muted-foreground">
                    Sent to <span className="font-medium text-foreground">{email.trim()}</span>
                  </p>
                  <fieldset className="mt-6" disabled={verifying}>
                    <legend className="sr-only">{codeLength}-digit verification code</legend>
                    <motion.div
                      key={shake}
                      animate={shake && !reduce ? { x: [0, -10, 9, -6, 4, 0] } : undefined}
                      transition={{ duration: 0.4 }}
                      className="flex justify-between gap-2"
                    >
                      {digits.map((d, i) => (
                        <React.Fragment key={i}>
                          {i === codeLength / 2 && <span className="self-center text-muted-foreground" aria-hidden>–</span>}
                          <input
                            ref={(el) => {
                              inputs.current[i] = el;
                            }}
                            value={d}
                            inputMode="numeric"
                            autoComplete={i === 0 ? "one-time-code" : "off"}
                            maxLength={1}
                            autoFocus={i === 0}
                            aria-label={`Digit ${i + 1} of ${codeLength}`}
                            aria-invalid={!!codeError}
                            aria-describedby={codeError ? `${uid}-code-err` : undefined}
                            onFocus={(e) => e.target.select()}
                            onKeyDown={(e) => onKey(i, e)}
                            onChange={(e) => {
                              const v = e.target.value.replace(/\D/g, "");
                              if (!v) return;
                              fill(i, v.slice(-1));
                            }}
                            onPaste={(e) => {
                              e.preventDefault();
                              fill(i, e.clipboardData.getData("text"));
                            }}
                            className={cn(
                              "h-13 w-full min-w-0 rounded-xl border bg-background text-center font-mono text-xl font-semibold tabular-nums shadow-xs outline-none transition sm:h-14",
                              "focus:border-ring focus:ring-4 focus:ring-ring/15",
                              d && "border-foreground/30",
                              codeError && "border-destructive focus:border-destructive focus:ring-destructive/15",
                              verifying && "opacity-60",
                            )}
                          />
                        </React.Fragment>
                      ))}
                    </motion.div>
                  </fieldset>
                  <div className="mt-3 min-h-5 text-xs" aria-live="assertive">
                    {verifying ? (
                      <span className="inline-flex items-center gap-1.5 text-muted-foreground">
                        <Loader2 className="size-3.5 animate-spin" /> Verifying…
                      </span>
                    ) : codeError ? (
                      <span id={`${uid}-code-err`} className="font-medium text-destructive">
                        {codeError}
                      </span>
                    ) : showDemoHint ? (
                      <span className="text-muted-foreground">
                        Demo code: <span className="font-mono text-foreground">123456</span> — try pasting it.
                      </span>
                    ) : null}
                  </div>
                  <div className="mt-5 flex items-center justify-between border-t pt-4 text-sm">
                    <span className="text-muted-foreground">Didn&apos;t get it?</span>
                    <ResendButton countdown={countdown} label={`${mm}:${ss}`} onResend={resend} />
                  </div>
                </motion.div>
              )}

              {step === "success" && (
                <motion.div key="success" initial={{ opacity: 0, scale: 0.96 }} animate={{ opacity: 1, scale: 1 }} className="py-4 text-center">
                  <div className="relative mx-auto size-16">
                    {!reduce &&
                      [0, 1].map((r) => (
                        <motion.span
                          key={r}
                          className="absolute inset-0 rounded-full border-2 border-emerald-500"
                          initial={{ scale: 1, opacity: 0.6 }}
                          animate={{ scale: 2, opacity: 0 }}
                          transition={{ duration: 1.2, delay: 0.2 + r * 0.25, ease: "easeOut" }}
                        />
                      ))}
                    <motion.div
                      initial={reduce ? false : { scale: 0 }}
                      animate={{ scale: 1 }}
                      transition={{ type: "spring", stiffness: 260, damping: 14 }}
                      className="grid size-16 place-items-center rounded-full bg-emerald-500 text-white shadow-lg shadow-emerald-500/30"
                    >
                      <svg viewBox="0 0 24 24" className="size-8" aria-hidden>
                        <motion.path
                          d="M5 12.5l4.5 4.5L19 7.5"
                          fill="none"
                          stroke="currentColor"
                          strokeWidth="2.6"
                          strokeLinecap="round"
                          strokeLinejoin="round"
                          initial={reduce ? false : { pathLength: 0 }}
                          animate={{ pathLength: 1 }}
                          transition={{ delay: 0.25, duration: 0.4 }}
                        />
                      </svg>
                    </motion.div>
                  </div>
                  <h1 className="mt-6 text-xl font-semibold tracking-tight">You&apos;re in</h1>
                  <p className="mt-1.5 text-sm text-muted-foreground">Signed in as {email.trim()}. Taking you to your dashboard…</p>
                  <button
                    type="button"
                    onClick={() => {
                      reset();
                      setEmail("");
                    }}
                    className="mt-6 inline-flex h-10 items-center gap-2 rounded-lg border px-4 text-sm font-medium transition hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                  >
                    <Check className="size-4" /> Start over
                  </button>
                </motion.div>
              )}
            </AnimatePresence>
          </div>
        </div>
        <p className="mt-5 text-center text-xs text-muted-foreground">
          Having trouble? <a href="#help" className="font-medium text-foreground underline-offset-4 hover:underline">Contact support</a>
        </p>
      </div>
    </section>
  );
}

function ResendButton({ countdown, label, onResend }: { countdown: number; label: string; onResend: () => void }) {
  return (
    <button
      type="button"
      onClick={onResend}
      disabled={countdown > 0}
      className="inline-flex items-center gap-1.5 rounded px-1 font-medium text-primary transition enabled:hover:underline disabled:cursor-not-allowed disabled:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring underline-offset-4"
    >
      <RotateCw className="size-3.5" />
      {countdown > 0 ? (
        <span>
          Resend in <span className="tabular-nums">{label}</span>
        </span>
      ) : (
        "Resend code"
      )}
    </button>
  );
}

function Envelope({ reduce }: { reduce: boolean }) {
  // Letter slides into the envelope, flap closes, envelope floats with a soft shadow.
  const t = (d: number, dur = 0.5) => (reduce ? { duration: 0 } : { delay: d, duration: dur, ease: [0.22, 1, 0.36, 1] as const });
  return (
    <div className="relative mx-auto h-36 w-44" aria-hidden>
      <motion.div
        className="absolute inset-0"
        animate={reduce ? undefined : { y: [0, -6, 0] }}
        transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut", delay: 1.4 }}
      >
        <svg viewBox="0 0 176 144" className="h-full w-full overflow-visible">
          <defs>
            <linearGradient id="aml-env" x1="0" y1="0" x2="0" y2="1">
              <stop offset="0" stopColor="var(--primary)" stopOpacity="0.95" />
              <stop offset="1" stopColor="var(--primary)" stopOpacity="0.75" />
            </linearGradient>
          </defs>
          {/* back of envelope */}
          <rect x="18" y="52" width="140" height="84" rx="10" fill="var(--primary)" opacity="0.35" />
          {/* open flap, behind the letter */}
          <motion.path
            d="M18 62 L88 18 L158 62 Z"
            fill="var(--primary)"
            opacity="0.55"
            style={{ transformOrigin: "50% 100%", transformBox: "fill-box" }}
            initial={reduce ? { scaleY: 0 } : { scaleY: 1 }}
            animate={{ scaleY: 0 }}
            transition={t(0.8, 0.22)}
          />
          {/* letter */}
          <motion.g initial={reduce ? false : { y: -52 }} animate={{ y: 0 }} transition={t(0.15, 0.7)}>
            <rect x="32" y="30" width="112" height="84" rx="6" fill="var(--card)" stroke="var(--border)" />
            <rect x="44" y="44" width="54" height="7" rx="3.5" fill="var(--primary)" opacity="0.8" />
            <rect x="44" y="58" width="86" height="5" rx="2.5" fill="var(--muted-foreground)" opacity="0.35" />
            <rect x="44" y="68" width="70" height="5" rx="2.5" fill="var(--muted-foreground)" opacity="0.35" />
          </motion.g>
          {/* front pocket */}
          <path d="M18 62 L88 104 L158 62 V126 a10 10 0 0 1 -10 10 H28 a10 10 0 0 1 -10 -10 Z" fill="url(#aml-env)" />
          <path d="M18 132 L72 94 M158 132 L104 94" stroke="white" strokeOpacity="0.25" strokeWidth="2" />
          {/* flap */}
          <motion.path
            d="M18 62 L88 108 L158 62 Z"
            fill="var(--primary)"
            style={{ transformOrigin: "50% 0%", transformBox: "fill-box" }}
            initial={reduce ? false : { scaleY: 0 }}
            animate={{ scaleY: 1 }}
            transition={t(1.0, 0.3)}
          />
          {/* notification badge */}
          <motion.g initial={reduce ? false : { scale: 0, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={reduce ? { duration: 0 } : { delay: 1.2, type: "spring", stiffness: 400, damping: 14 }} style={{ transformOrigin: "50% 50%", transformBox: "fill-box" }}>
            <circle cx="154" cy="56" r="13" fill="#10b981" stroke="var(--card)" strokeWidth="4" />
            <path d="M148.5 56.5l3.8 3.6 7-7.4" fill="none" stroke="white" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round" />
          </motion.g>
        </svg>
      </motion.div>
      <motion.div
        className="absolute -bottom-3 left-1/2 h-3 w-28 -translate-x-1/2 rounded-full bg-foreground/10 blur-md"
        animate={reduce ? undefined : { scaleX: [1, 0.85, 1], opacity: [0.8, 0.5, 0.8] }}
        transition={{ duration: 3.2, repeat: Infinity, ease: "easeInOut", delay: 1.4 }}
      />
      {!reduce &&
        [
          [10, 30, 0.2],
          [150, 16, 0.5],
          [164, 100, 0.9],
          [4, 96, 1.1],
        ].map(([x, y, d], i) => (
          <motion.span
            key={i}
            className="absolute size-1.5 rounded-full bg-primary"
            style={{ left: x, top: y }}
            initial={{ scale: 0, opacity: 0 }}
            animate={{ scale: [0, 1, 0], opacity: [0, 1, 0] }}
            transition={{ duration: 1.6, delay: 1 + d, repeat: Infinity, repeatDelay: 1.4 }}
          />
        ))}
    </div>
  );
}

More in Auth & Onboarding

View all →