A two-factor sign-in step with segmented one-time-code entry, verify/error states, and a method picker for authenticator, SMS, or security-key fallback.
npx shadcn@latest add @paragon/login-mfa"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import {
KeyRound,
Loader2,
MessageSquare,
ShieldCheck,
Smartphone,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type MfaMethod = "app" | "sms" | "key";
export interface MfaMethodOption {
id: MfaMethod;
label: string;
hint: string;
}
const METHOD_ICON: Record<MfaMethod, React.ElementType> = {
app: Smartphone,
sms: MessageSquare,
key: KeyRound,
};
const DEFAULT_METHODS: MfaMethodOption[] = [
{ id: "app", label: "Authenticator app", hint: "Enter the 6-digit code" },
{ id: "sms", label: "Text message", hint: "Code sent to •••• 4471" },
{ id: "key", label: "Security key", hint: "Use your passkey or hardware key" },
];
export interface LoginMfaProps extends React.ComponentProps<"div"> {
/** Account being verified. */
email?: string;
/** Number of code digits. */
length?: number;
/** Available fallback methods. */
methods?: MfaMethodOption[];
/** The correct code, for demo verification. Never logged. */
expected?: string;
/** Disables enter animation. */
static?: boolean;
}
type Status = "idle" | "verifying" | "error" | "done";
/**
* A sign-in MFA step: segmented code entry with a method picker for fallback.
* Digits are individual boxes driven by one hidden input so paste, arrow keys,
* and backspace all work. Submitting runs a short verify, then either shakes
* the field on mismatch (reduced-motion swaps the shake for a color pulse) or
* blur-crossfades to a verified state. The typed code is never logged.
*/
export function LoginMfa({
email = "morgan@acme.com",
length = 6,
methods = DEFAULT_METHODS,
expected = "418902",
static: isStatic = false,
className,
...props
}: LoginMfaProps) {
const reduced = useReducedMotion();
const [method, setMethod] = React.useState<MfaMethod>("app");
const [code, setCode] = React.useState("");
const [status, setStatus] = React.useState<Status>("idle");
const inputRef = React.useRef<HTMLInputElement>(null);
const timer = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const active = methods.find((m) => m.id === method) ?? methods[0];
const submit = React.useCallback(
(value: string) => {
setStatus("verifying");
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => {
if (value === expected) {
setStatus("done");
} else {
setStatus("error");
setCode("");
inputRef.current?.focus();
}
}, 700);
},
[expected],
);
const onChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (status === "verifying" || status === "done") return;
const next = e.target.value.replace(/\D/g, "").slice(0, length);
setCode(next);
if (status === "error") setStatus("idle");
if (next.length === length) submit(next);
};
const done = status === "done";
return (
<div
data-slot="login-mfa"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-6 text-card-foreground shadow-border",
className,
)}
style={
!isStatic && !reduced
? ({
animation: "login-mfa-enter var(--duration-base) var(--ease-out)",
} as React.CSSProperties)
: undefined
}
{...props}
>
<style href="paragon-login-mfa" precedence="paragon">{`
@keyframes login-mfa-enter {
from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@keyframes login-mfa-shake {
0%,100% { transform: translateX(0); }
20% { transform: translateX(-5px); }
40% { transform: translateX(5px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
@media (prefers-reduced-motion: reduce) {
[data-slot="login-mfa"] { animation: none !important; }
[data-mfa-field] { animation: none !important; }
}
`}</style>
<AnimatePresence mode="wait" initial={false}>
{done ? (
<motion.div
key="done"
className="flex flex-col items-center py-4 text-center"
initial={reduced ? false : { opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
transition={{ duration: 0.25, ease: [0.22, 1, 0.36, 1] }}
>
<span className="flex size-11 items-center justify-center rounded-full bg-success/12 text-success">
<ShieldCheck className="size-6" />
</span>
<h2 className="mt-3 text-base font-semibold">Identity verified</h2>
<p className="mt-1 text-sm text-muted-foreground">
Signing in as {email}
</p>
</motion.div>
) : (
<motion.div
key="entry"
initial={false}
exit={reduced ? undefined : { opacity: 0, filter: "blur(4px)" }}
transition={{ duration: 0.15 }}
>
<div className="flex items-center gap-2.5">
<span className="flex size-9 items-center justify-center rounded-lg bg-secondary text-foreground">
<ShieldCheck className="size-4.5" />
</span>
<div className="min-w-0">
<h2 className="text-sm font-semibold">Two-factor required</h2>
<p className="truncate text-xs text-muted-foreground">
{active.hint}
</p>
</div>
</div>
{/* Code field */}
<button
type="button"
onClick={() => inputRef.current?.focus()}
data-mfa-field
className="relative mt-5 flex w-full justify-between gap-2 outline-none"
style={
status === "error" && !reduced
? ({
animation: "login-mfa-shake 0.4s var(--ease-out)",
} as React.CSSProperties)
: undefined
}
>
{Array.from({ length }, (_, i) => {
const char = code[i];
const isCursor = i === code.length && status !== "verifying";
return (
<span
key={i}
className={cn(
"flex h-12 flex-1 items-center justify-center rounded-lg border bg-background font-mono text-lg tabular-nums transition-[border-color,box-shadow,color] duration-(--duration-fast)",
status === "error"
? "border-destructive text-destructive"
: char
? "border-foreground/25"
: "border-input",
isCursor &&
"border-ring ring-2 ring-ring/30",
)}
>
{char ? "•" : ""}
</span>
);
})}
<input
ref={inputRef}
value={code}
onChange={onChange}
inputMode="numeric"
autoComplete="one-time-code"
aria-label="One-time verification code"
maxLength={length}
disabled={status === "verifying"}
className="absolute inset-0 h-full w-full cursor-default opacity-0"
/>
</button>
<div
className="mt-2 flex h-4 items-center justify-center text-xs"
aria-live="polite"
>
{status === "verifying" && (
<span className="flex items-center gap-1.5 text-muted-foreground">
<Loader2 className="size-3 animate-spin" />
Verifying…
</span>
)}
{status === "error" && (
<span className="text-destructive">
That code didn't match. Try again.
</span>
)}
</div>
{/* Method fallback */}
<div className="mt-4 border-t pt-4">
<p className="text-xs font-medium text-muted-foreground">
Verify another way
</p>
<div className="mt-2 flex flex-col gap-1.5">
{methods.map((m) => {
const Icon = METHOD_ICON[m.id];
const selected = m.id === method;
return (
<button
key={m.id}
type="button"
onClick={() => {
setMethod(m.id);
setCode("");
setStatus("idle");
inputRef.current?.focus();
}}
aria-pressed={selected}
className={cn(
"pressable flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-sm transition-colors duration-(--duration-fast) outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "bg-secondary text-foreground"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
<Icon className="size-4 shrink-0" />
<span className="flex-1">{m.label}</span>
{selected && (
<span className="size-1.5 rounded-full bg-foreground" />
)}
</button>
);
})}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}