Sections

Auth Form

A centered sign-in card with SSO buttons, an email magic-link flow, invalid-input shake, and a blur-swap success panel.

Install

npx shadcn@latest add @paragon/auth-form

Also installs: button

auth-form.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, MailCheck } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";

function GoogleMark(props: React.ComponentProps<"svg">) {
  return (
    <svg viewBox="0 0 24 24" aria-hidden {...props}>
      <path
        fill="#4285F4"
        d="M23.52 12.27c0-.85-.08-1.67-.22-2.45H12v4.64h6.46a5.52 5.52 0 0 1-2.4 3.62v3h3.88c2.27-2.09 3.58-5.17 3.58-8.81Z"
      />
      <path
        fill="#34A853"
        d="M12 24c3.24 0 5.96-1.07 7.94-2.91l-3.88-3.01c-1.07.72-2.45 1.15-4.06 1.15-3.13 0-5.78-2.11-6.72-4.95H1.27v3.11A12 12 0 0 0 12 24Z"
      />
      <path
        fill="#FBBC05"
        d="M5.28 14.28a7.2 7.2 0 0 1 0-4.56V6.61H1.27a12 12 0 0 0 0 10.78l4.01-3.11Z"
      />
      <path
        fill="#EA4335"
        d="M12 4.77c1.76 0 3.34.61 4.59 1.8l3.44-3.44A11.98 11.98 0 0 0 1.27 6.61l4.01 3.11C6.22 6.88 8.87 4.77 12 4.77Z"
      />
    </svg>
  );
}

function GitHubMark(props: React.ComponentProps<"svg">) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden {...props}>
      <path d="M12 .3a12 12 0 0 0-3.79 23.39c.6.11.82-.26.82-.58l-.01-2.04c-3.34.73-4.04-1.61-4.04-1.61-.55-1.39-1.33-1.76-1.33-1.76-1.09-.74.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.66-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.13-.3-.54-1.52.11-3.18 0 0 1.01-.32 3.3 1.23a11.5 11.5 0 0 1 6.01 0c2.29-1.55 3.3-1.23 3.3-1.23.65 1.66.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.81 5.63-5.49 5.92.43.38.82 1.11.82 2.24l-.01 3.32c0 .32.21.7.82.58A12 12 0 0 0 12 .3Z" />
    </svg>
  );
}

const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export interface AuthFormProps extends React.ComponentProps<"section"> {
  /** Product name shown in the heading. */
  productName?: string;
  /** Called with the email when a magic link is requested. */
  onMagicLink?: (email: string) => void;
}

/**
 * A centered sign-in card: SSO buttons, email magic-link flow with an
 * invalid-email shake, and a blur-swap to a "check your inbox" panel on
 * success. The panel swap holds a shared min-height so nothing below jumps.
 */
export function AuthForm({
  productName = "Vantage",
  onMagicLink,
  className,
  ...props
}: AuthFormProps) {
  const reducedMotion = useReducedMotion();
  const inputId = React.useId();
  const errorId = React.useId();
  const [email, setEmail] = React.useState("");
  const [error, setError] = React.useState<string | null>(null);
  const [sent, setSent] = React.useState(false);
  const [shakes, setShakes] = React.useState(0);

  const submit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!EMAIL_RE.test(email.trim())) {
      setError("Enter a valid work email address.");
      setShakes((s) => s + 1);
      return;
    }
    setError(null);
    setSent(true);
    onMagicLink?.(email.trim());
  };

  // Exits run at roughly half the enter duration, on the exit curve.
  const exitTransition = {
    duration: 0.15,
    ease: [0.4, 0, 1, 1] as const,
  };
  const panelMotion = reducedMotion
    ? {
        initial: { opacity: 0 },
        animate: { opacity: 1 },
        exit: { opacity: 0, transition: exitTransition },
      }
    : {
        initial: { opacity: 0, y: 12, filter: "blur(4px)" },
        animate: { opacity: 1, y: 0, filter: "blur(0px)" },
        exit: {
          opacity: 0,
          y: -12,
          filter: "blur(4px)",
          transition: exitTransition,
        },
      };

  return (
    <section
      aria-label={`Sign in to ${productName}`}
      className={cn("mx-auto w-full max-w-sm", className)}
      {...props}
    >
      <div className="rounded-xl bg-card p-6 text-card-foreground shadow-border sm:p-8">
        <div className="flex flex-col items-center text-center">
          <div
            aria-hidden
            className="flex size-10 items-center justify-center rounded-lg bg-primary text-sm font-semibold text-primary-foreground"
          >
            {productName.charAt(0)}
          </div>
          <h2 className="mt-4 text-lg font-semibold tracking-tight">
            Sign in to {productName}
          </h2>
          <p className="mt-1 text-sm text-muted-foreground">
            Deploy, observe, and roll back with confidence.
          </p>
        </div>

        <div className="relative mt-6 min-h-[17.5rem]">
          <AnimatePresence mode="popLayout" initial={false}>
            {sent ? (
              <motion.div
                key="sent"
                {...panelMotion}
                transition={{ type: "spring", duration: 0.4, bounce: 0 }}
                className="flex min-h-[17.5rem] flex-col items-center justify-center text-center"
              >
                <div className="flex size-11 items-center justify-center rounded-full bg-emerald-600/10 text-emerald-700 dark:bg-emerald-400/10 dark:text-emerald-400">
                  <MailCheck aria-hidden className="size-5" />
                </div>
                <h3 className="mt-4 text-base font-semibold">
                  Check your inbox
                </h3>
                <p className="mt-1.5 text-sm text-pretty text-muted-foreground">
                  We sent a magic link to{" "}
                  <span className="font-medium text-foreground">{email}</span>.
                  It expires in 15 minutes.
                </p>
                <Button
                  variant="ghost"
                  size="sm"
                  className="mt-5 text-muted-foreground"
                  onClick={() => setSent(false)}
                >
                  <ArrowLeft aria-hidden />
                  Back to sign in
                </Button>
              </motion.div>
            ) : (
              <motion.div
                key="form"
                {...panelMotion}
                transition={{ type: "spring", duration: 0.4, bounce: 0 }}
              >
                <div className="grid gap-2.5">
                  <Button variant="outline" className="w-full">
                    <GoogleMark className="size-4" />
                    Continue with Google
                  </Button>
                  <Button variant="outline" className="w-full">
                    <GitHubMark className="size-4" />
                    Continue with GitHub
                  </Button>
                </div>

                <div
                  role="separator"
                  className="my-5 flex items-center gap-3 text-xs text-muted-foreground"
                >
                  <span aria-hidden className="h-px flex-1 bg-border" />
                  or
                  <span aria-hidden className="h-px flex-1 bg-border" />
                </div>

                <form onSubmit={submit} noValidate>
                  <label
                    htmlFor={inputId}
                    className="text-sm font-medium"
                  >
                    Work email
                  </label>
                  <motion.div
                    key={`shake-${shakes}`}
                    initial={false}
                    animate={
                      shakes > 0 && !reducedMotion
                        ? { x: [0, -7, 7, -4, 4, 0] }
                        : { x: 0 }
                    }
                    transition={{ duration: 0.35, ease: "easeOut" }}
                    className="mt-1.5"
                  >
                    <input
                      id={inputId}
                      type="email"
                      autoComplete="email"
                      placeholder="you@company.com"
                      value={email}
                      aria-invalid={error ? true : undefined}
                      aria-describedby={error ? errorId : undefined}
                      onChange={(e) => {
                        setEmail(e.target.value);
                        if (error) setError(null);
                      }}
                      className={cn(
                        "h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm outline-none transition-[border-color,box-shadow] duration-150 ease-out",
                        "placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card",
                        error && "border-destructive",
                      )}
                    />
                  </motion.div>
                  <div aria-live="polite" role="status">
                    {error && (
                      <motion.p
                        id={errorId}
                        initial={
                          reducedMotion
                            ? { opacity: 0 }
                            : { opacity: 0, y: -4 }
                        }
                        animate={{ opacity: 1, y: 0 }}
                        transition={{ duration: 0.2, ease: "easeOut" }}
                        className="mt-1.5 text-xs text-destructive"
                      >
                        {error}
                      </motion.p>
                    )}
                  </div>
                  <Button type="submit" className="mt-4 w-full">
                    Continue with email
                  </Button>
                </form>

                <p className="mt-4 text-center text-xs text-pretty text-muted-foreground">
                  We’ll email you a magic link for a password-free sign in.
                </p>
              </motion.div>
            )}
          </AnimatePresence>
        </div>
      </div>

      <footer className="mt-4 flex items-center justify-center gap-1 text-xs text-muted-foreground">
        <a
          href="#terms"
          className="rounded-sm px-1 py-0.5 transition-colors duration-150 hover:text-foreground"
        >
          Terms
        </a>
        <span aria-hidden>·</span>
        <a
          href="#privacy"
          className="rounded-sm px-1 py-0.5 transition-colors duration-150 hover:text-foreground"
        >
          Privacy
        </a>
        <span aria-hidden>·</span>
        <a
          href="#support"
          className="rounded-sm px-1 py-0.5 transition-colors duration-150 hover:text-foreground"
        >
          Support
        </a>
      </footer>
    </section>
  );
}