Two-factor verification
Authenticator code with resend timer, backup-code fallback and success state.
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import { KeyRound, ShieldCheck, Smartphone } from "lucide-react";
import { Button } from "@/components/ui/button";
import { OtpInput } from "@/components/ui/otp-input";
/** Two-factor verification: authenticator code with resend timer, backup code fallback, success state. */
export function TwoFactor() {
const [method, setMethod] = React.useState<"app" | "backup">("app");
const [code, setCode] = React.useState("");
const [state, setState] = React.useState<"idle" | "checking" | "error" | "ok">("idle");
const [backup, setBackup] = React.useState("");
const [wait, setWait] = React.useState(30);
const [attempt, setAttempt] = React.useState(0);
React.useEffect(() => {
if (wait <= 0) return;
const t = setTimeout(() => setWait((w) => w - 1), 1000);
return () => clearTimeout(t);
}, [wait]);
const verify = async (v: string) => {
setState("checking");
await new Promise((r) => setTimeout(r, 700));
if (v === "424242" || v.toUpperCase() === "ABCD-EFGH") setState("ok");
else {
setState("error");
setAttempt((a) => a + 1);
}
};
return (
<div className="mx-auto w-full max-w-sm rounded-xl border border-border bg-background p-6 text-center shadow-elev-2 sm:p-8">
<AnimatePresence mode="wait" initial={false}>
{state === "ok" ? (
<motion.div key="ok" initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }}>
<motion.span initial={{ scale: 0.4 }} animate={{ scale: 1 }} transition={{ type: "spring", bounce: 0.5 }} className="mx-auto grid size-12 place-items-center rounded-full bg-emerald-500/15 text-emerald-500"><ShieldCheck className="size-6" /></motion.span>
<h2 className="mt-4 text-xl font-semibold">Verified</h2>
<p className="mt-1 text-sm text-muted-foreground">Signing you in…</p>
<Button variant="ghost" size="sm" className="mt-4" onClick={() => { setState("idle"); setCode(""); }}>Reset demo</Button>
</motion.div>
) : (
<motion.div key={method} initial={{ opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: -12 }}>
<span className="mx-auto grid size-12 place-items-center rounded-full bg-muted">{method === "app" ? <Smartphone className="size-5" /> : <KeyRound className="size-5" />}</span>
<h2 className="mt-4 text-xl font-semibold">{method === "app" ? "Enter your code" : "Use a backup code"}</h2>
<p className="mt-1 text-sm text-muted-foreground">{method === "app" ? "Open your authenticator app. (Demo: 424242)" : "Each backup code works once. (Demo: ABCD-EFGH)"}</p>
<div className="mt-6 flex justify-center">
{method === "app" ? (
<OtpInput key={attempt} value={code} onValueChange={(v) => { setCode(v); if (state === "error") setState("idle"); }} onComplete={verify} error={state === "error"} disabled={state === "checking"} />
) : (
<form onSubmit={(e) => { e.preventDefault(); verify(backup); }} className="w-full space-y-3">
<input value={backup} onChange={(e) => setBackup(e.target.value)} placeholder="XXXX-XXXX" aria-label="Backup code" className="h-11 w-full rounded-md border border-input bg-transparent text-center font-mono tracking-widest outline-none focus:border-foreground/40" />
<Button type="submit" className="w-full" loading={state === "checking"}>Verify</Button>
</form>
)}
</div>
<p className="mt-3 h-5 text-sm text-destructive" role="alert">{state === "error" ? "That code didn't work. Try again." : ""}</p>
{method === "app" && (
<p className="mt-2 text-sm text-muted-foreground">
Didn't get it?{" "}
{wait > 0 ? <span className="tabular-nums">Resend in {wait}s</span> : <button type="button" onClick={() => setWait(30)} className="font-medium text-foreground underline underline-offset-4">Resend</button>}
</p>
)}
<button type="button" onClick={() => { setMethod(method === "app" ? "backup" : "app"); setState("idle"); }} className="mt-5 text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline">
{method === "app" ? "Use a backup code" : "Use authenticator app"}
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
export default TwoFactor;What's inside
- Auto-submit on 6 digits
- Resend timer
- Backup code
- Success state
When to use
- Second step after sign-in or before sensitive actions.
When not to use
- Enrolment (showing the QR code).
Installation
pnpm dlx shadcn@latest add https://lodeui.com/r/two-factor.jsonLands in components/blocks/two-factor.tsx with sample data you replace with your own.
The CLI also installs motion, lucide-react and every Lode component it's built from.
Usage
Import it from @/components/blocks/two-factor after installing. It ships with sample data, so it renders straight away; swap in your own data when you're ready.
import { TwoFactor } from "@/components/blocks/two-factor";
export default function Page() {
return (
<main className="grid min-h-dvh place-items-center p-6">
<TwoFactor />
</main>
);
}API reference
TwoFactor
Two-factor verification with auto-submit on six digits, a resend timer, backup code fallback and a success state. Also the default export. No props: sample data sits at the top of the file, ready to swap for your own.
No props.
Keyboard interactions
| Keys | Action |
|---|---|
| 0–9 | Enter the code |
| ⌘VorCtrl+V | Paste the whole code |
| Tab | Reach resend and backup code |
Accessibility
- One-time-code autocomplete; errors use role="alert".
Motion
- Wrong codes shake; success crossfades in.
Responsive behavior
- Fits 320px.
Performance
- A one-second timeout drives the resend countdown.
Notes for AI
- Verify on the server. The demo accepts 424242, or the backup code ABCD-EFGH; anything else shows the error.
This page as Markdown, for assistants and coding agents:
/blocks/two-factor/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt