A two-step ACH/wire transfer: an entry form with routing and account validation, then a cross-sliding review step showing the masked account and total with fees.
npx shadcn@latest add @paragon/bank-transfer-form"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { ArrowLeft, Building2, Check, ShieldCheck } from "lucide-react";
import { cn } from "@/lib/utils";
type Rail = "ach" | "wire";
export interface BankTransferValue {
rail: Rail;
accountName: string;
routing: string;
account: string;
amount: number;
memo: string;
}
export interface BankTransferFormProps
extends Omit<React.ComponentProps<"div">, "onSubmit"> {
currency?: string;
onSubmit?: (value: BankTransferValue) => void;
}
const railFee: Record<Rail, number> = { ach: 0, wire: 25 };
const railEta: Record<Rail, string> = { ach: "1–3 business days", wire: "Same day" };
/**
* A two-step bank transfer: an entry form (ACH or wire, routing, account,
* amount, memo) that validates routing to 9 digits and account to a minimum,
* then a review step that slides in showing the masked account and the total
* with any wire fee before you confirm. The two panels cross-slide with the
* house physics; reduced motion cross-fades instead. Back returns to editing
* with values intact.
*/
export function BankTransferForm({
currency = "USD",
onSubmit,
className,
...props
}: BankTransferFormProps) {
const reduced = useReducedMotion();
const baseId = React.useId();
const [step, setStep] = React.useState<"edit" | "review">("edit");
const [rail, setRail] = React.useState<Rail>("ach");
const [accountName, setAccountName] = React.useState("");
const [routing, setRouting] = React.useState("");
const [account, setAccount] = React.useState("");
const [amount, setAmount] = React.useState("");
const [memo, setMemo] = React.useState("");
const money = new Intl.NumberFormat("en-US", { style: "currency", currency });
const amountNum = Number(amount) || 0;
const fee = railFee[rail];
const total = amountNum + fee;
const valid =
accountName.trim().length > 1 &&
routing.length === 9 &&
account.length >= 6 &&
amountNum > 0;
const field =
"h-9 w-full rounded-lg border bg-transparent px-3 text-sm outline-none transition-[border-color,box-shadow] duration-150 ease-out placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25";
const slide = (dir: 1 | -1) => ({
initial: reduced ? { opacity: 0 } : { opacity: 0, x: dir * 24 },
animate: { opacity: 1, x: 0 },
exit: reduced ? { opacity: 0 } : { opacity: 0, x: dir * -24 },
transition: { duration: 0.25, ease: [0.22, 1, 0.36, 1] as const },
});
return (
<div
className={cn(
"w-full max-w-md overflow-hidden rounded-xl bg-card p-5 shadow-border",
className,
)}
{...props}
>
<AnimatePresence mode="wait" initial={false}>
{step === "edit" ? (
<motion.form
key="edit"
{...slide(1)}
onSubmit={(e) => {
e.preventDefault();
if (valid) setStep("review");
}}
>
<h3 className="text-sm font-medium">Send a transfer</h3>
<div
role="radiogroup"
aria-label="Transfer method"
className="mt-4 grid grid-cols-2 gap-2"
>
{(["ach", "wire"] as Rail[]).map((r) => {
const active = rail === r;
return (
<button
key={r}
type="button"
role="radio"
aria-checked={active}
onClick={() => setRail(r)}
className={cn(
"pressable flex flex-col items-start rounded-lg border p-3 text-left transition-[border-color,background-color] duration-150 ease-out",
active ? "border-primary bg-accent" : "hover:bg-accent/50",
)}
>
<span className="text-sm font-medium uppercase">{r}</span>
<span className="text-[12px] text-muted-foreground">
{railEta[r]}
{railFee[r] > 0 ? ` · ${money.format(railFee[r])} fee` : " · Free"}
</span>
</button>
);
})}
</div>
<div className="mt-4 flex flex-col gap-3">
<div>
<label
htmlFor={`${baseId}-name`}
className="mb-1.5 block text-sm font-medium"
>
Account holder
</label>
<input
id={`${baseId}-name`}
value={accountName}
onChange={(e) => setAccountName(e.target.value)}
placeholder="Acme Supplies LLC"
className={field}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label
htmlFor={`${baseId}-routing`}
className="mb-1.5 block text-sm font-medium"
>
Routing number
</label>
<input
id={`${baseId}-routing`}
inputMode="numeric"
value={routing}
onChange={(e) =>
setRouting(e.target.value.replace(/\D/g, "").slice(0, 9))
}
placeholder="021000021"
className={cn(field, "tabular-nums")}
/>
</div>
<div>
<label
htmlFor={`${baseId}-account`}
className="mb-1.5 block text-sm font-medium"
>
Account number
</label>
<input
id={`${baseId}-account`}
inputMode="numeric"
value={account}
onChange={(e) =>
setAccount(e.target.value.replace(/\D/g, "").slice(0, 17))
}
placeholder="000123456789"
className={cn(field, "tabular-nums")}
/>
</div>
</div>
<div>
<label
htmlFor={`${baseId}-amount`}
className="mb-1.5 block text-sm font-medium"
>
Amount
</label>
<div className="relative">
<span className="pointer-events-none absolute inset-y-0 left-3 flex items-center text-sm text-muted-foreground">
$
</span>
<input
id={`${baseId}-amount`}
inputMode="decimal"
value={amount}
onChange={(e) =>
setAmount(e.target.value.replace(/[^\d.]/g, ""))
}
placeholder="0.00"
className={cn(field, "pl-7 text-right tabular-nums")}
/>
</div>
</div>
<div>
<label
htmlFor={`${baseId}-memo`}
className="mb-1.5 block text-sm font-medium"
>
Memo <span className="text-muted-foreground">(optional)</span>
</label>
<input
id={`${baseId}-memo`}
value={memo}
onChange={(e) => setMemo(e.target.value)}
placeholder="Invoice #10428"
className={field}
/>
</div>
</div>
<button
type="submit"
disabled={!valid}
className="pressable mt-5 flex h-10 w-full items-center justify-center rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
>
Review transfer
</button>
</motion.form>
) : (
<motion.div key="review" {...slide(-1)}>
<div className="flex items-center gap-2">
<button
type="button"
aria-label="Back to edit"
onClick={() => setStep("edit")}
className="pressable flex size-8 items-center justify-center rounded-md text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
<ArrowLeft className="size-4" aria-hidden />
</button>
<h3 className="text-sm font-medium">Confirm transfer</h3>
</div>
<div className="mt-4 rounded-lg border p-4">
<div className="flex items-center gap-3">
<span className="flex size-10 items-center justify-center rounded-lg bg-muted">
<Building2 className="size-5 text-muted-foreground" aria-hidden />
</span>
<div>
<p className="text-sm font-medium">{accountName}</p>
<p className="text-[13px] text-muted-foreground tabular-nums">
•••• {account.slice(-4)} · {rail.toUpperCase()}
</p>
</div>
</div>
<dl className="mt-4 flex flex-col gap-2 border-t pt-4 text-sm">
<div className="flex justify-between">
<dt className="text-muted-foreground">Amount</dt>
<dd className="tabular-nums">{money.format(amountNum)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">
{rail === "wire" ? "Wire fee" : "ACH fee"}
</dt>
<dd className="tabular-nums">{money.format(fee)}</dd>
</div>
<div className="flex justify-between">
<dt className="text-muted-foreground">Arrives</dt>
<dd>{railEta[rail]}</dd>
</div>
{memo && (
<div className="flex justify-between">
<dt className="text-muted-foreground">Memo</dt>
<dd className="max-w-[60%] truncate">{memo}</dd>
</div>
)}
<div className="flex justify-between border-t pt-2 text-base font-semibold">
<dt>Total</dt>
<dd className="tabular-nums">{money.format(total)}</dd>
</div>
</dl>
</div>
<p className="mt-3 flex items-center gap-1.5 text-[12px] text-muted-foreground">
<ShieldCheck className="size-3.5 shrink-0" aria-hidden />
Verified against the recipient's records. Transfers are final once
sent.
</p>
<button
type="button"
onClick={() =>
onSubmit?.({
rail,
accountName,
routing,
account,
amount: amountNum,
memo,
})
}
className="pressable mt-4 flex h-10 w-full items-center justify-center gap-2 rounded-lg bg-primary text-sm font-medium text-primary-foreground transition-colors duration-150 ease-out hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<Check className="size-4" aria-hidden />
Send {money.format(total)}
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}