Two-Factor Setup
Set up an authenticator app in three steps: scan a code or copy the key, confirm a 6-digit code, then save backup codes.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/two-factor-setup.json'use client'
import { useId, useMemo, useState } from 'react'
import './two-factor-setup.css'
export interface TwoFactorSetupProps {
secret: string
verify: (code: string) => boolean | Promise<boolean>
backupCodes: string[]
account?: string
step?: 1 | 2 | 3
onDone?: () => void
fill?: 'dark' | 'light'
className?: string
}
/** Deterministic QR-like pattern for the demo; pass a real QR SVG in production. */
function Code({ seed }: { seed: string }) {
const cells = useMemo(() => {
let h = 0; for (const c of seed) h = (h * 31 + c.charCodeAt(0)) >>> 0
const out: [number, number][] = []
for (let y = 0; y < 21; y++) for (let x = 0; x < 21; x++) {
const finder = (x < 7 && y < 7) || (x > 13 && y < 7) || (x < 7 && y > 13)
if (finder) { const fx = x > 13 ? x - 14 : x, fy = y > 13 ? y - 14 : y; if (fx === 0 || fx === 6 || fy === 0 || fy === 6 || (fx > 1 && fx < 5 && fy > 1 && fy < 5)) out.push([x, y]); continue }
h = (h * 1103515245 + 12345) >>> 0
if ((h >> 16) & 1) out.push([x, y])
}
return out
}, [seed])
return <svg className="sd-2fa__qr" viewBox="0 0 21 21" role="img" aria-label="QR code for your authenticator app" shapeRendering="crispEdges">{cells.map(([x, y]) => <rect key={x + '-' + y} x={x} y={y} width="1" height="1" />)}</svg>
}
/** Authenticator setup: scan, verify, save backup codes. */
export function TwoFactorSetup({ secret, verify, backupCodes, account = 'you@company.com', step: s0 = 1, onDone, fill = 'dark', className }: TwoFactorSetupProps) {
const [step, setStep] = useState<number>(s0)
const [code, setCode] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
const [copied, setCopied] = useState('')
const [saved, setSaved] = useState(false)
const id = useId()
const copy = (t: string, k: string) => { navigator.clipboard?.writeText(t).catch(() => {}); setCopied(k); setTimeout(() => setCopied(''), 1400) }
const check = async () => {
setBusy(true); const ok = await verify(code); setBusy(false)
if (ok) { setErr(''); setStep(3) } else { setErr('That code didn’t match. Codes change every 30 seconds.'); setCode('') }
}
const head = (k: string, t: string, p: string) => <><p className="sd-2fa__k">Step {step} of 3 · {k}</p><h3 className="sd-2fa__t">{t}</h3><p className="sd-2fa__p">{p}</p></>
return (
<div className={['sd-2fa', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-2fa__st" aria-hidden>{[1, 2, 3].map((i) => <i key={i} data-on={i <= step || undefined} />)}</div>
{step === 1 && (
<div className="sd-2fa__body">
{head('Scan', 'Scan with your app', `Use Google Authenticator, 1Password or similar to add ${account}.`)}
<Code seed={secret} />
<div className="sd-2fa__key"><span aria-label="Secret key">{secret.replace(/(.{4})/g, '$1 ').trim()}</span><button type="button" className="sd-2fa__sm" onClick={() => copy(secret, 'key')}>{copied === 'key' ? 'Copied' : 'Copy'}</button></div>
<button type="button" className="sd-2fa__go" onClick={() => setStep(2)}>I’ve added it</button>
</div>
)}
{step === 2 && (
<form className="sd-2fa__body" onSubmit={(e) => { e.preventDefault(); if (code.length === 6) check() }}>
{head('Verify', 'Enter the 6-digit code', 'Type the code your app shows for this account.')}
<input className="sd-2fa__code" inputMode="numeric" autoComplete="one-time-code" autoFocus aria-label="Verification code" aria-invalid={!!err} aria-describedby={`${id}-e`}
maxLength={6} value={code} onChange={(e) => { setCode(e.target.value.replace(/\D/g, '')); setErr('') }} placeholder="000000" />
<p id={`${id}-e`} className="sd-2fa__err" role="alert">{err}</p>
<button type="submit" className="sd-2fa__go" disabled={code.length < 6 || busy}>{busy ? 'Checking…' : 'Verify'}</button>
</form>
)}
{step === 3 && (
<div className="sd-2fa__body">
{head('Backup', 'Save your backup codes', 'Each code works once if you lose your phone. Keep them somewhere safe.')}
<div className="sd-2fa__codes" aria-label="Backup codes">{backupCodes.map((c) => <span key={c}>{c}</span>)}</div>
<div className="sd-2fa__row">
<button type="button" className="sd-2fa__sm" onClick={() => copy(backupCodes.join('\n'), 'codes')}>{copied === 'codes' ? 'Copied' : 'Copy all'}</button>
<a className="sd-2fa__sm" style={{ flex: 1, padding: 8, textAlign: 'center', textDecoration: 'none', color: 'inherit' }} download="backup-codes.txt" href={`data:text/plain,${encodeURIComponent(backupCodes.join('\n'))}`}>Download</a>
</div>
<label className="sd-2fa__ck"><input type="checkbox" checked={saved} onChange={(e) => setSaved(e.target.checked)} />I’ve saved my backup codes</label>
<button type="button" className="sd-2fa__go" disabled={!saved} onClick={() => { setStep(4); onDone?.() }}>Turn on two-factor</button>
</div>
)}
{step === 4 && <div className="sd-2fa__done" role="status"><i aria-hidden>✓</i><h3 className="sd-2fa__t">Two-factor is on</h3><p className="sd-2fa__p">You’ll enter a code when you sign in on a new device.</p></div>}
</div>
)
}.sd-2fa { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-2fa[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-2fa :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-2fa :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-2fa .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-2fa { width: min(100%, 330px); padding: 18px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-2fa__st { display: flex; gap: 6px; margin-bottom: 14px; }
.sd-2fa__st i { flex: 1; height: 3px; border-radius: 99px; background: var(--soft); transition: background-color 250ms; }
.sd-2fa__st i[data-on] { background: var(--fg); }
.sd-2fa__k { margin: 0; font-size: 11.5px; font-weight: 600; color: var(--mute); text-transform: uppercase; letter-spacing: 0.05em; }
.sd-2fa__t { margin: 4px 0 6px; font-size: 18px; letter-spacing: -0.01em; }
.sd-2fa__p { margin: 0 0 14px; font-size: 13px; line-height: 1.5; color: var(--mute); }
.sd-2fa__qr { display: block; width: 150px; height: 150px; margin: 0 auto 14px; padding: 10px; border-radius: 14px; background: #fff; }
.sd-2fa__qr rect { fill: #111; }
.sd-2fa__key { display: flex; align-items: center; gap: 8px; padding: 9px 10px 9px 12px; border-radius: 11px; background: var(--soft); font: 600 13px/1.2 ui-monospace, monospace; letter-spacing: 0.06em; }
.sd-2fa__key span { flex: 1; min-width: 0; overflow-wrap: anywhere; }
.sd-2fa__sm { padding: 5px 10px; border-radius: 8px; font: 500 12px/1 ui-sans-serif, system-ui; letter-spacing: 0; box-shadow: inset 0 0 0 1px var(--line); white-space: nowrap; }
.sd-2fa__code { width: 100%; height: 50px; border: 0; border-radius: 12px; background: var(--soft); color: var(--fg); font: 600 24px/1 ui-monospace, monospace; letter-spacing: 0.4em; text-align: center; outline: 0; }
.sd-2fa__code:focus { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-2fa__code[aria-invalid='true'] { box-shadow: inset 0 0 0 1.5px var(--fg); animation: sd-2fa-shake 360ms; }
.sd-2fa__err { min-height: 18px; margin: 6px 2px 0; font-size: 12.5px; }
.sd-2fa__codes { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; padding: 12px; border-radius: 12px; background: var(--soft); font: 500 13px/1.3 ui-monospace, monospace; text-align: center; }
.sd-2fa__row { display: flex; gap: 6px; margin: 10px 0; }
.sd-2fa__row .sd-2fa__sm { flex: 1; padding: 8px; }
.sd-2fa__ck { display: flex; align-items: center; gap: 8px; font-size: 13px; cursor: pointer; }
.sd-2fa__ck input { accent-color: var(--fg); width: 16px; height: 16px; margin: 0; }
.sd-2fa__go { display: block; width: 100%; height: 44px; margin-top: 14px; border-radius: 12px; background: var(--strong) !important; color: var(--on) !important; font-size: 14px; font-weight: 600; }
.sd-2fa__go:disabled { opacity: 0.35; cursor: not-allowed; }
.sd-2fa__done { text-align: center; padding: 30px 0 10px; animation: sd-2fa-in 260ms ease-out; }
.sd-2fa__done i { display: grid; place-items: center; width: 52px; height: 52px; margin: 0 auto 12px; border-radius: 99px; background: var(--strong); color: var(--on); font-style: normal; font-size: 24px; }
.sd-2fa__body { animation: sd-2fa-in 240ms ease-out; }
@keyframes sd-2fa-in { from { opacity: 0; transform: translateX(10px); } }
@keyframes sd-2fa-shake { 25%, 75% { transform: translateX(-5px); } 50% { transform: translateX(5px); } }
@media (prefers-reduced-motion: reduce) { .sd-2fa *, .sd-2fa *::before, .sd-2fa *::after { animation: none !important; transition: none !important; } }Overview
Step one shows a scannable code and the secret key with copy. Step two checks a code from the app, with a clear error on a mismatch.
Step three lists backup codes to copy or download and asks you to confirm you saved them before finishing.
When to use
- Account security settings, admin onboarding.
When not to use
- SMS-only verification flows.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/two-factor-setup.json$
npx shadcn@latest add https://sundaeui.dev/r/two-factor-setup.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/two-factor-setup.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/two-factor-setup.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy two-factor-setup.tsx and two-factor-setup.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { TwoFactorSetup } from '@/components/sundae/two-factor-setup'
<TwoFactorSetup />Props
| Prop | Type | Default |
|---|---|---|
secret*Base32 secret. | string | — |
verify*Check a code. | (code: string) => boolean | Promise<boolean> | — |
backupCodes*Recovery codes. | string[] | — |
onDoneFinished. | () => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- Steps are announced with “Step 2 of 3”. The code input uses one-time-code autocomplete.
- Errors are linked to the input with aria-describedby.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-2fa; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
- Demo accepts 123456 as the verification code.
FAQ
- Is Two-Factor Setup free?
- Yes. Two-Factor Setup is free for personal and commercial projects, with full source on this page.
- How do I install Two-Factor Setup?
- Run npx shadcn@latest add https://sundaeui.dev/r/two-factor-setup.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Two-Factor Setup support keyboard and screen readers?
- Steps are announced with “Step 2 of 3”. The code input uses one-time-code autocomplete. Errors are linked to the input with aria-describedby.
- Does Two-Factor Setup respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Resend Code
Forms · RefinedPIN Lock
Forms · RefinedOTP Field
Forms · Refined- Pro
Drop Zone
Forms · Refined - Pro
Onboarding Flow
Forms · Refined - Pro
Wheel Picker
Forms · Refined