A payment card that plays the moment a transfer goes through. It opens in a processing state: a spinning ring around an exchange icon, a status line, and separate From and To rows with their amounts greyed out. After a couple of seconds the icon flips into a green badge with a check that draws itself, the title and reference line slide to their completed text, the exchange rate appears and the two rows close the gap between them to become one joined block. A receipt link becomes active and a replay button runs it again. The card is 384 pixels wide at most and fits small phones.
// Adapted from Kokonut UI (https://github.com/kokonut-labs/kokonutui/blob/main/components/kokonutui/currency-transfer.tsx)
// Copyright (c) 2025 kokonutUI. MIT License: https://we.inc/components/licenses
import { useEffect, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { ArrowDown, ArrowUp, ArrowUpDown, RotateCcw } from "lucide-react";
// The transfer shown on the card.
const transfer = {
reference: "TRF-8K2M-41QX",
rate: "1 USD = 0.92 EUR",
rateTime: "Rate locked at 10:45",
from: { symbol: "$", amount: "500.00 USD", account: "Harbour Checking", last4: "4589" },
to: { symbol: "€", amount: "460.00 EUR", account: "Lindqvist Studio", last4: "7823" },
};
// How long the card stays in the processing state before it completes.
const PROCESSING_MS = 2300;
const EASE = [0.22, 1, 0.36, 1] as const;
type Phase = "processing" | "done";
/** Text that slides out upwards and in from below when its content changes. */
function Swap({ id, className, instant, children }: { id: string; className: string; instant: boolean; children: string }) {
return (
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={id}
className={`block ${className}`}
initial={instant ? false : { opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={instant ? undefined : { opacity: 0, y: -12 }}
transition={{ duration: 0.3, ease: EASE }}
>
{children}
</motion.span>
</AnimatePresence>
);
}
export default function MoneyTransferStatusCardWithMergingRows() {
const reduce = useReducedMotion();
const [phase, setPhase] = useState<Phase>(reduce ? "done" : "processing");
const [run, setRun] = useState(0);
const done = phase === "done";
useEffect(() => {
if (phase !== "processing") return;
const timer = setTimeout(() => setPhase("done"), reduce ? 600 : PROCESSING_MS);
return () => clearTimeout(timer);
}, [phase, run, reduce]);
const replay = () => {
setPhase("processing");
setRun((n) => n + 1);
};
const rows = [
{ key: "from", label: "From", Icon: ArrowUp, data: transfer.from, joined: "rounded-b-none border-b-transparent" },
{ key: "to", label: "To", Icon: ArrowDown, data: transfer.to, joined: "rounded-t-none" },
];
return (
<div className="flex w-full items-center justify-center bg-emerald-50 px-4 py-14">
<style>{`
@keyframes money-transfer-status-spin { to { transform: rotate(360deg); } }
.money-transfer-status-spin { animation: money-transfer-status-spin 1.4s linear infinite; }
@media (prefers-reduced-motion: reduce) { .money-transfer-status-spin { animation: none; } }
`}</style>
<section className={`w-full max-w-sm rounded-3xl border bg-white p-6 shadow-xl shadow-emerald-900/10 transition-colors duration-700 ${done ? "border-emerald-300" : "border-zinc-200"}`} aria-labelledby="money-transfer-status-title">
<div className="flex h-[104px] items-center justify-center">
<div className="relative flex h-24 w-24 items-center justify-center">
<motion.div className="absolute inset-0 rounded-full bg-emerald-400/25 blur-2xl" animate={{ opacity: done ? 1 : 0.4, scale: done ? 1.15 : 0.9 }} transition={{ duration: 0.8, ease: EASE }} aria-hidden="true" />
<AnimatePresence mode="wait" initial={false}>
{done ? (
<motion.div
key="done"
className="relative flex h-20 w-20 items-center justify-center rounded-full bg-emerald-500 text-white shadow-lg shadow-emerald-500/40"
initial={reduce ? false : { opacity: 0, scale: 0.6, rotate: -90 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 18 }}
>
<svg viewBox="0 0 48 48" className="h-11 w-11" fill="none" stroke="currentColor" strokeWidth="4.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<motion.path d="M12 25l8.5 8.5L36 16" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.45, delay: 0.15, ease: EASE }} />
</svg>
</motion.div>
) : (
<motion.div
key="processing"
className="relative flex h-20 w-20 items-center justify-center rounded-full bg-white text-emerald-600 shadow-[0_0_20px_rgba(16,185,129,0.18)]"
initial={reduce ? false : { opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduce ? undefined : { opacity: 0, scale: 0.6, rotate: 90 }}
transition={{ duration: 0.3, ease: "easeInOut" }}
>
<span className="money-transfer-status-spin absolute inset-0 rounded-full border-[3px] border-emerald-100 border-l-emerald-500 border-t-emerald-300" aria-hidden="true" />
<ArrowUpDown className="h-9 w-9" aria-hidden="true" />
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="mt-4 text-center" aria-live="polite">
<h2 id="money-transfer-status-title" className="h-7 overflow-hidden text-lg font-semibold uppercase tracking-tight text-zinc-900">
<Swap id={phase} className="" instant={!!reduce}>
{done ? "Transfer completed" : "Transfer in progress"}
</Swap>
</h2>
<p className="mt-1 h-4 overflow-hidden text-xs font-medium text-emerald-700">
<Swap id={phase} className="" instant={!!reduce}>
{done ? `Reference ${transfer.reference}` : "Checking details with both banks"}
</Swap>
</p>
</div>
{/* The two rows start apart and close into one block once the money has moved. */}
<motion.div className="mt-5 flex flex-col" initial={false} animate={{ gap: done ? 0 : 12 }} transition={{ duration: reduce ? 0 : 0.6, ease: [0.32, 0.72, 0, 1] }}>
{rows.map(({ key, label, Icon, data, joined }) => (
<div key={key} className={`rounded-xl border border-zinc-200 bg-zinc-50 p-3 transition-all duration-500 ${done ? joined : ""}`}>
<p className="flex items-center gap-1.5 text-xs font-medium text-zinc-600">
<Icon className="h-3 w-3" aria-hidden="true" />
{label}
</p>
<div className="mt-1.5 flex items-center gap-3">
<span className="flex h-8 w-8 items-center justify-center rounded-lg border border-zinc-300 bg-white text-sm font-semibold text-zinc-900 shadow-sm" aria-hidden="true">
{data.symbol}
</span>
<div className="min-w-0">
<p className={`font-semibold tracking-tight transition-colors duration-500 ${done ? "text-zinc-900" : "text-zinc-500"}`}>{data.amount}</p>
<p className="truncate text-xs text-zinc-600">
{data.account} <span aria-hidden="true">••••</span>
<span className="sr-only">ending </span>
{data.last4}
</p>
</div>
</div>
</div>
))}
</motion.div>
<p className="mt-3 h-4 overflow-hidden text-center text-xs text-zinc-600">
<Swap id={phase} className="" instant={!!reduce}>
{done ? `${transfer.rate} · ${transfer.rateTime}` : "Calculating exchange rate..."}
</Swap>
</p>
<div className="mt-5 flex gap-3">
<a
href="#"
aria-disabled={!done}
tabIndex={done ? 0 : -1}
className={`inline-flex h-11 flex-1 items-center justify-center rounded-xl text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2 ${done ? "bg-zinc-900 text-white hover:bg-zinc-700" : "pointer-events-none bg-zinc-100 text-zinc-500"}`}
>
View receipt
</a>
<button
type="button"
onClick={replay}
className="inline-flex h-11 items-center justify-center gap-2 rounded-xl border border-zinc-300 bg-white px-4 text-sm font-semibold text-zinc-900 transition hover:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-600 focus-visible:ring-offset-2"
>
<RotateCcw className="h-4 w-4" aria-hidden="true" />
Replay
</button>
</div>
</section>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/money-transfer-status-card-with-merging-rows.json
They sit in a flex column whose `gap` is animated from 12 pixels to zero. At the same moment the top row loses its bottom corners and border and the bottom row loses its top corners, so the pair reads as one box.
It is an SVG path whose `pathLength` animates from 0 to 1, which strokes it from start to end. The badge behind it springs in with a slight rotation first, then the check draws.
Add a third phase such as `failed`. Reuse the badge with a red background and a cross path, keep the rows apart and change the title and reference text. The swap component handles any text change.
Each swapping line sits in a box with a fixed height and hidden overflow. The old text slides up and out before the new text slides in, so nothing around it moves.
The card is presentation only. Always render amounts and account endings from your server response, and never show a full account number.
Adapted from Currency Transfer in Kokonut UI, used under its open-source licence. Licences and credits
Related: Checkout Payment Form with Order Summary, Multi-Step Loading Sequence with Drawn Checkmarks, Activity Rings Card with Day Switcher.
More animated cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.