A passkey/WebAuthn enrollment card walking through intro, device prompt, and enrolled states with a pulsing biometric medallion.
npx shadcn@latest add @paragon/passkey-setup"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Fingerprint, KeyRound, Loader2, ScanFace } from "lucide-react";
import { cn } from "@/lib/utils";
export interface PasskeySetupProps extends React.ComponentProps<"div"> {
/** Relying-party / product name. */
appName?: string;
/** Account the passkey is bound to. */
account?: string;
/** Device label shown once enrolled. */
device?: string;
/** Disables enter animation. */
static?: boolean;
}
type Step = "intro" | "prompting" | "enrolled";
/**
* A passkey / WebAuthn enrollment card. The single primary button drives a
* three-step flow: intro → a simulated platform prompt (spinner) → an enrolled
* confirmation, each swapped with a blur-crossfade so the card never jumps. A
* pulsing fingerprint ring signals the prompt; reduced motion drops the pulse
* and the crossfade becomes a plain fade. Deterministic — a fixed timeout, no
* randomness.
*/
export function PasskeySetup({
appName = "Acme Console",
account = "morgan@acme.com",
device = "MacBook Pro · Touch ID",
static: isStatic = false,
className,
...props
}: PasskeySetupProps) {
const reduced = useReducedMotion();
const [step, setStep] = React.useState<Step>("intro");
const timer = React.useRef<ReturnType<typeof setTimeout>>(null);
React.useEffect(() => {
return () => {
if (timer.current) clearTimeout(timer.current);
};
}, []);
const enroll = () => {
setStep("prompting");
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setStep("enrolled"), 1400);
};
const reset = () => {
if (timer.current) clearTimeout(timer.current);
setStep("intro");
};
return (
<div
data-slot="passkey-setup"
className={cn(
"w-full max-w-sm rounded-xl bg-card p-6 text-center text-card-foreground shadow-border",
className,
)}
style={
!isStatic && !reduced
? ({
animation: "passkey-enter var(--duration-base) var(--ease-out)",
} as React.CSSProperties)
: undefined
}
{...props}
>
<style href="paragon-passkey-setup" precedence="paragon">{`
@keyframes passkey-enter {
from { opacity: 0; transform: translateY(8px); filter: blur(4px); }
to { opacity: 1; transform: translateY(0); filter: blur(0); }
}
@keyframes passkey-pulse {
0%, 100% { transform: scale(1); opacity: 0.5; }
50% { transform: scale(1.35); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
[data-slot="passkey-setup"] { animation: none !important; }
[data-passkey-pulse] { animation: none !important; opacity: 0 !important; }
}
`}</style>
{/* Icon medallion */}
<div className="relative mx-auto flex size-16 items-center justify-center">
<span
data-passkey-pulse
aria-hidden
className={cn(
"pointer-events-none absolute inset-0 rounded-full",
step === "enrolled" ? "bg-success/25" : "bg-primary/20",
)}
style={
step === "prompting"
? ({
animation: "passkey-pulse 1.4s var(--ease-out) infinite",
} as React.CSSProperties)
: { opacity: 0 }
}
/>
<span
className={cn(
"relative flex size-14 items-center justify-center rounded-full transition-colors duration-(--duration-base)",
step === "enrolled"
? "bg-success/12 text-success"
: "bg-secondary text-foreground",
)}
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={step}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{step === "intro" && <Fingerprint className="size-7" />}
{step === "prompting" && <ScanFace className="size-7" />}
{step === "enrolled" && <Check className="size-7" />}
</motion.span>
</AnimatePresence>
</span>
</div>
<div className="mt-4 min-h-[3.5rem]">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={step}
initial={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)", y: 6 }}
animate={{ opacity: 1, filter: "blur(0px)", y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, filter: "blur(4px)", y: -6 }}
transition={{ duration: 0.2, ease: [0.22, 1, 0.36, 1] }}
>
{step === "intro" && (
<>
<h2 className="text-base font-semibold">Set up a passkey</h2>
<p className="mt-1 text-sm text-muted-foreground">
Sign in to {appName} with your fingerprint, face, or device PIN —
no password to remember.
</p>
</>
)}
{step === "prompting" && (
<>
<h2 className="text-base font-semibold">Confirm on your device</h2>
<p className="mt-1 text-sm text-muted-foreground">
Follow the system prompt to create your passkey.
</p>
</>
)}
{step === "enrolled" && (
<>
<h2 className="text-base font-semibold">Passkey ready</h2>
<p className="mt-1 text-sm text-muted-foreground">
{device} can now sign in {account}.
</p>
</>
)}
</motion.div>
</AnimatePresence>
</div>
<div className="mt-5">
{step === "enrolled" ? (
<button
type="button"
onClick={reset}
className="pressable inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-card px-4 text-sm font-medium shadow-border transition-[box-shadow] duration-(--duration-fast) hover:shadow-border-hover outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
Add another passkey
</button>
) : (
<button
type="button"
onClick={enroll}
disabled={step === "prompting"}
className="pressable inline-flex h-9 w-full items-center justify-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-[opacity] duration-(--duration-fast) hover:opacity-90 disabled:opacity-70 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{step === "prompting" ? (
<>
<Loader2 className="size-4 animate-spin" />
Waiting for device…
</>
) : (
<>
<KeyRound className="size-4" />
Create passkey
</>
)}
</button>
)}
</div>
</div>
);
}