Security

Login MFA

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.

Install

npx shadcn@latest add @paragon/login-mfa

login-mfa.tsx

"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&apos;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>
  );
}