All blocks

Two-Factor Setup

Pick a method, verify a code, save backup codes.

Installation

Add it with the shadcn CLI, open it in v0, or read the source.

$ npx shadcn@latest add https://stepperize.com/r/two-factor.json
Dependencies
  • @stepperize/react
  • lucide-react
Requirements
  • React 18 or later
  • Tailwind CSS

Source

import { defineStepper } from "@stepperize/react";
import { ShieldCheck, Smartphone } from "lucide-react";
import { useState } from "react";
import { buttonVariants } from "@/components/ui/button";
import {
	InputOTP,
	InputOTPGroup,
	InputOTPSlot,
} from "@/components/ui/input-otp";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";

const { Stepper } = defineStepper([
	{ id: "method", title: "Choose a method" },
	{ id: "verify", title: "Enter the code" },
	{ id: "backup", title: "Save backup codes" },
]);

export function TwoFactorBlock() {
	const [enabled, setEnabled] = useState(false);

	return (
		<Stepper.Root
			linear
			className="w-full max-w-sm rounded-xl border bg-background p-6 shadow-sm"
		>
			{({ stepper }) => (
				<>
					<div className="mb-4 flex items-center justify-between">
						<span className="grid size-9 place-items-center rounded-lg bg-primary/10 text-primary">
							<ShieldCheck className="size-5" />
						</span>
						<Stepper.List className="flex gap-1.5">
							<Stepper.Items>
								{(step) => (
									<Stepper.Item key={step.id} step={step.id}>
										<Stepper.Indicator className="block h-1.5 w-6 rounded-full transition-colors data-[status=active]:bg-primary data-[status=previous]:bg-primary data-[status=upcoming]:bg-muted" />
									</Stepper.Item>
								)}
							</Stepper.Items>
						</Stepper.List>
					</div>

					<h3 className="text-base font-semibold">{stepper.current.title}</h3>

					<div className="mt-4 min-h-32">
						<Stepper.Content step="method">
							<RadioGroup defaultValue="app">
								<Method
									value="app"
									icon={Smartphone}
									label="Authenticator app"
									hint="Recommended"
								/>
								<Method
									value="sms"
									icon={ShieldCheck}
									label="Text message"
									hint="SMS code"
								/>
							</RadioGroup>
						</Stepper.Content>

						<Stepper.Content step="verify" className="space-y-3">
							<p className="text-sm text-muted-foreground">
								Enter the 6-digit code from your app.
							</p>
							<InputOTP maxLength={6}>
								<InputOTPGroup className="w-full justify-between">
									{[0, 1, 2, 3, 4, 5].map((i) => (
										<InputOTPSlot
											key={i}
											index={i}
											className="size-11 text-lg"
										/>
									))}
								</InputOTPGroup>
							</InputOTP>
						</Stepper.Content>

						<Stepper.Content step="backup" className="space-y-3">
							<p className="text-sm text-muted-foreground">
								{enabled
									? "Two-factor authentication is enabled."
									: "Store these somewhere safe."}
							</p>
							<div className="grid grid-cols-2 gap-2 rounded-lg border bg-muted/30 p-3 font-mono text-sm">
								{["9F2A-7C1B", "4E8D-22A9", "B0C3-9911", "77AF-DE02"].map(
									(code) => (
										<span key={code}>{code}</span>
									),
								)}
							</div>
						</Stepper.Content>
					</div>

					<Stepper.Actions className="mt-6 flex justify-between">
						<Stepper.Prev className={buttonVariants({ variant: "outline" })}>
							Back
						</Stepper.Prev>
						{enabled ? (
							<button
								type="button"
								onClick={() => {
									setEnabled(false);
									stepper.reset();
								}}
								className={buttonVariants()}
							>
								Start over
							</button>
						) : stepper.isLast ? (
							<button
								type="button"
								onClick={() => setEnabled(true)}
								className={buttonVariants()}
							>
								Enable 2FA
							</button>
						) : (
							<Stepper.Next className={buttonVariants()}>Continue</Stepper.Next>
						)}
					</Stepper.Actions>
				</>
			)}
		</Stepper.Root>
	);
}

function Method({
	value,
	icon: Icon,
	label,
	hint,
}: {
	value: string;
	icon: React.ComponentType<{ className?: string }>;
	label: string;
	hint: string;
}) {
	return (
		<Label className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 text-sm font-normal transition-colors has-data-checked:border-primary has-data-checked:bg-primary/5">
			<RadioGroupItem value={value} />
			<Icon className="size-5 text-muted-foreground" />
			<span className="font-medium">{label}</span>
			<span className="ml-auto text-xs text-muted-foreground">{hint}</span>
		</Label>
	);
}

When to use it

Enabling 2FA — choose a method, verify a one-time code, then store backup codes.

Accessibility

The OTP input is labelled and announces remaining digits; success/error states are textual, not color-only.

Customization

Swap the method list and wire the verify step to your API; backup codes render from generated data.

Related blocks