Fintech & Payments

KYC Verification Steps

A document checklist that carries each item through upload, verifying, and verified states — the check draws its stroke on verify and the row shakes once on error.

Install

npx shadcn@latest add @paragon/kyc-verification-steps

kyc-verification-steps.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { AlertCircle, Loader2, Upload } from "lucide-react";
import { cn } from "@/lib/utils";

export type KycStatus = "idle" | "uploaded" | "verifying" | "verified" | "error";

export interface KycStep {
  id: string;
  /** Step title, e.g. "Government ID". */
  title: string;
  /** Helper line under the title. */
  description?: string;
  status: KycStatus;
  /** Error copy shown when status is "error". */
  error?: string;
}

export interface KycVerificationStepsProps extends React.ComponentProps<"ol"> {
  steps: KycStep[];
  /** Called when a step's upload control is activated. */
  onUpload?: (id: string) => void;
}

/** The animated check that draws its stroke once on verify. */
function DrawnCheck() {
  const id = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  return (
    <>
      <style href={`paragon-kyc-check-${id}`} precedence="paragon">{`
        @keyframes paragon-kyc-draw-${id} {
          from { stroke-dashoffset: 22; }
          to { stroke-dashoffset: 0; }
        }
        [data-kyc-check="${id}"] {
          stroke-dasharray: 22;
          animation: paragon-kyc-draw-${id} 300ms var(--ease-out) both;
        }
        @media (prefers-reduced-motion: reduce) {
          [data-kyc-check="${id}"] { animation: none; stroke-dashoffset: 0; }
        }
      `}</style>
      <svg viewBox="0 0 24 24" fill="none" className="size-3.5" aria-hidden>
        <path
          data-kyc-check={id}
          d="M5 12.5l4.5 4.5L19 7"
          stroke="currentColor"
          strokeWidth="2.5"
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
    </>
  );
}

/**
 * A KYC checklist that carries each document through idle → uploaded →
 * verifying → verified (or error). The status node blur-swaps between states;
 * on verify the check draws its stroke once, and on error the whole row shakes
 * a single time (a one-shot keyframe on its own class, so re-triggering the
 * error replays the shake without restarting anything else). Verifying shows a
 * spinner — the only looping motion, and it's the standard linear spin.
 */
export function KycVerificationSteps({
  steps,
  onUpload,
  className,
  ...props
}: KycVerificationStepsProps) {
  const reduced = useReducedMotion();

  return (
    <ol className={cn("w-full max-w-md space-y-2", className)} {...props}>
      <style href="paragon-kyc-steps" precedence="paragon">{`
        @keyframes paragon-kyc-shake {
          0% { translate: 0; }
          25% { translate: -5px 0; }
          50% { translate: 4px 0; }
          75% { translate: -2px 0; }
          100% { translate: 0; }
        }
        @media (prefers-reduced-motion: reduce) {
          .paragon-kyc-shake { animation: none !important; }
        }
      `}</style>

      {steps.map((step) => (
        <KycRow
          key={step.id}
          step={step}
          reduced={!!reduced}
          onUpload={onUpload}
        />
      ))}
    </ol>
  );
}

function KycRow({
  step,
  reduced,
  onUpload,
}: {
  step: KycStep;
  reduced: boolean;
  onUpload?: (id: string) => void;
}) {
  const [shaking, setShaking] = React.useState(false);
  const prevStatus = React.useRef(step.status);

  React.useEffect(() => {
    if (step.status === "error" && prevStatus.current !== "error" && !reduced) {
      setShaking(true);
    }
    prevStatus.current = step.status;
  }, [step.status, reduced]);

  const node = (() => {
    switch (step.status) {
      case "verified":
        return (
          <span
            key="verified"
            className="flex size-6 items-center justify-center rounded-full bg-success text-success-foreground"
          >
            <DrawnCheck />
          </span>
        );
      case "verifying":
        return (
          <span
            key="verifying"
            className="flex size-6 items-center justify-center rounded-full bg-muted text-muted-foreground"
          >
            <Loader2 className="size-3.5 animate-spin motion-reduce:animate-none" aria-hidden />
          </span>
        );
      case "error":
        return (
          <span
            key="error"
            className="flex size-6 items-center justify-center rounded-full bg-destructive/15 text-destructive"
          >
            <AlertCircle className="size-4" aria-hidden />
          </span>
        );
      case "uploaded":
        return (
          <span
            key="uploaded"
            className="flex size-6 items-center justify-center rounded-full bg-muted text-[11px] font-medium tabular-nums text-muted-foreground"
          >

          </span>
        );
      default:
        return (
          <span
            key="idle"
            className="size-6 rounded-full border border-dashed border-input"
            aria-hidden
          />
        );
    }
  })();

  const showUpload = step.status === "idle";

  return (
    <li
      className={cn(
        "flex items-center gap-3 rounded-xl bg-card p-3 shadow-border",
        shaking && "paragon-kyc-shake",
      )}
      style={shaking ? { animation: "paragon-kyc-shake 260ms both" } : undefined}
      onAnimationEnd={(e) => {
        if (e.animationName === "paragon-kyc-shake") setShaking(false);
      }}
    >
      <span className="flex size-6 shrink-0 items-center justify-center">
        {reduced ? (
          node
        ) : (
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={step.status}
              initial={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
              animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
              exit={{ opacity: 0, scale: 0.5, filter: "blur(4px)" }}
              transition={{ type: "spring", duration: 0.3, bounce: 0 }}
            >
              {node}
            </motion.span>
          </AnimatePresence>
        )}
      </span>

      <span className="flex min-w-0 flex-1 flex-col">
        <span className="text-sm font-medium">{step.title}</span>
        <span
          className={cn(
            "text-[13px]",
            step.status === "error" ? "text-destructive" : "text-muted-foreground",
          )}
        >
          {step.status === "error"
            ? (step.error ?? "Verification failed")
            : step.status === "verifying"
              ? "Verifying…"
              : step.status === "verified"
                ? "Verified"
                : (step.description ?? "")}
        </span>
      </span>

      {showUpload && (
        <button
          type="button"
          onClick={() => onUpload?.(step.id)}
          className="pressable inline-flex h-8 shrink-0 items-center gap-1.5 rounded-lg px-3 text-[13px] font-medium shadow-border transition-[box-shadow,background-color] duration-150 ease-out hover:shadow-border-hover focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
        >
          <Upload className="size-3.5" aria-hidden />
          Upload
        </button>
      )}
    </li>
  );
}