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.
npx shadcn@latest add @paragon/kyc-verification-steps"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>
);
}