Security

Passkey Setup

A passkey/WebAuthn enrollment card walking through intro, device prompt, and enrolled states with a pulsing biometric medallion.

Install

npx shadcn@latest add @paragon/passkey-setup

passkey-setup.tsx

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