A centered sign-in card with SSO buttons, an email magic-link flow, invalid-input shake, and a blur-swap success panel.
npx shadcn@latest add @paragon/auth-formAlso installs: button
"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>
);
}