Fintech & Payments

Bank Transfer Form

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.

Install

npx shadcn@latest add @paragon/bank-transfer-form

bank-transfer-form.tsx

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