Web InnoventixFreeCode

Payment Form

Original · free

card payment form with formatting

byWeb InnoventixReact + Tailwind
formpaymentforms
Open

Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.

npx shadcn@latest add https://webinnoventix.com/r/form-payment.json
form-payment.tsx
"use client";

import { useId, useMemo, useRef, useState } from "react";
import type { ChangeEvent, FormEvent, ReactNode, RefObject } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";

type CardBrand = "visa" | "mastercard" | "amex" | "discover" | "unknown";

type FieldKey = "number" | "name" | "expiry" | "cvc" | "zip";

type FieldErrors = Partial<Record<FieldKey, string>>;

const AMOUNT = "228.00";

function detectBrand(digits: string): CardBrand {
  if (/^4/.test(digits)) return "visa";
  if (/^(5[1-5]|2(2[2-9]|[3-6]\d|7[01]|720))/.test(digits)) return "mastercard";
  if (/^3[47]/.test(digits)) return "amex";
  if (/^(6011|65|64[4-9])/.test(digits)) return "discover";
  return "unknown";
}

function formatCardNumber(digits: string, brand: CardBrand): string {
  if (brand === "amex") {
    return [digits.slice(0, 4), digits.slice(4, 10), digits.slice(10, 15)]
      .filter(Boolean)
      .join(" ");
  }
  const groups: string[] = [];
  for (let i = 0; i < digits.length; i += 4) groups.push(digits.slice(i, i + 4));
  return groups.join(" ");
}

function formatExpiry(value: string): string {
  let d = value.replace(/\D/g, "");
  if (d.length === 1 && Number(d) > 1) d = "0" + d;
  d = d.slice(0, 4);
  if (d.length <= 2) return d;
  return `${d.slice(0, 2)}/${d.slice(2)}`;
}

function luhnValid(digits: string): boolean {
  if (!digits) return false;
  let sum = 0;
  let alt = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let n = Number(digits[i]);
    if (alt) {
      n *= 2;
      if (n > 9) n -= 9;
    }
    sum += n;
    alt = !alt;
  }
  return sum % 10 === 0;
}

function previewNumber(digits: string, brand: CardBrand): string {
  const groups = brand === "amex" ? [4, 6, 5] : [4, 4, 4, 4];
  let idx = 0;
  return groups
    .map((g) => {
      let s = "";
      for (let i = 0; i < g; i++) {
        s += digits[idx] ?? "•";
        idx++;
      }
      return s;
    })
    .join("  ");
}

function getErrors(f: {
  number: string;
  name: string;
  expiry: string;
  cvc: string;
  zip: string;
}): FieldErrors {
  const e: FieldErrors = {};
  const digits = f.number.replace(/\D/g, "");
  const brand = detectBrand(digits);
  const expectedLen = brand === "amex" ? 15 : 16;

  if (!digits) e.number = "Enter your card number.";
  else if (digits.length < expectedLen) e.number = "Card number is incomplete.";
  else if (!luhnValid(digits)) e.number = "This card number looks invalid.";

  if (!f.name.trim()) e.name = "Enter the name on the card.";
  else if (f.name.trim().length < 2) e.name = "That name is too short.";

  const exp = f.expiry.replace(/\D/g, "");
  if (exp.length < 4) e.expiry = "Enter a valid expiry.";
  else {
    const mm = Number(exp.slice(0, 2));
    const yy = 2000 + Number(exp.slice(2, 4));
    const now = new Date();
    const curYear = now.getFullYear();
    const curMonth = now.getMonth() + 1;
    if (mm < 1 || mm > 12) e.expiry = "Invalid month.";
    else if (yy < curYear || (yy === curYear && mm < curMonth))
      e.expiry = "This card has expired.";
  }

  const cvcLen = brand === "amex" ? 4 : 3;
  const cvcDigits = f.cvc.replace(/\D/g, "");
  if (!cvcDigits) e.cvc = "Enter the security code.";
  else if (cvcDigits.length < cvcLen) e.cvc = `CVC must be ${cvcLen} digits.`;

  const zip = f.zip.trim();
  if (!zip) e.zip = "Enter your billing ZIP.";
  else if (!/^[A-Za-z0-9][A-Za-z0-9 -]{2,9}$/.test(zip)) e.zip = "That ZIP looks off.";

  return e;
}

function BrandMark({ brand, className }: { brand: CardBrand; className?: string }): ReactNode {
  const cls = className ?? "h-5 w-auto";
  if (brand === "visa") {
    return (
      <span className={`inline-flex items-center font-black italic tracking-tight ${cls}`}>
        VISA
      </span>
    );
  }
  if (brand === "amex") {
    return (
      <span className={`inline-flex items-center font-bold tracking-widest ${cls}`}>AMEX</span>
    );
  }
  if (brand === "discover") {
    return (
      <span className={`inline-flex items-center gap-1 font-bold tracking-tight ${cls}`}>
        DISC
        <span className="inline-block h-2 w-2 rounded-full bg-amber-500" />
      </span>
    );
  }
  if (brand === "mastercard") {
    return (
      <svg viewBox="0 0 40 24" className={cls} role="img" aria-label="Mastercard">
        <circle cx="15" cy="12" r="9" fill="#f59e0b" />
        <circle cx="25" cy="12" r="9" fill="#f43f5e" fillOpacity="0.85" />
      </svg>
    );
  }
  return (
    <svg viewBox="0 0 40 24" className={cls} role="img" aria-label="Card" fill="none">
      <rect x="1.5" y="3.5" width="37" height="17" rx="3" stroke="currentColor" strokeWidth="1.6" />
      <rect x="1.5" y="7.5" width="37" height="4" fill="currentColor" opacity="0.5" />
    </svg>
  );
}

const BRAND_LABEL: Record<CardBrand, string> = {
  visa: "Visa",
  mastercard: "Mastercard",
  amex: "American Express",
  discover: "Discover",
  unknown: "your card",
};

export default function PaymentForm() {
  const reduce = useReducedMotion();
  const uid = useId();
  const fid = (k: string) => `${uid}-${k}`;

  const [number, setNumber] = useState("");
  const [name, setName] = useState("");
  const [expiry, setExpiry] = useState("");
  const [cvc, setCvc] = useState("");
  const [zip, setZip] = useState("");
  const [touched, setTouched] = useState<Partial<Record<FieldKey, boolean>>>({});
  const [cvcFocused, setCvcFocused] = useState(false);
  const [status, setStatus] = useState<"idle" | "processing" | "success">("idle");

  const numberRef = useRef<HTMLInputElement>(null);
  const nameRef = useRef<HTMLInputElement>(null);
  const expiryRef = useRef<HTMLInputElement>(null);
  const cvcRef = useRef<HTMLInputElement>(null);
  const zipRef = useRef<HTMLInputElement>(null);

  const digits = number.replace(/\D/g, "");
  const brand = detectBrand(digits);
  const errors = useMemo(
    () => getErrors({ number, name, expiry, cvc, zip }),
    [number, name, expiry, cvc, zip],
  );

  const show = (k: FieldKey): string | undefined =>
    touched[k] || status !== "idle" ? errors[k] : undefined;

  const onNumber = (e: ChangeEvent<HTMLInputElement>) => {
    const raw = e.target.value.replace(/\D/g, "");
    const b = detectBrand(raw);
    const max = b === "amex" ? 15 : 16;
    setNumber(formatCardNumber(raw.slice(0, max), b));
  };
  const onName = (e: ChangeEvent<HTMLInputElement>) =>
    setName(e.target.value.replace(/[^A-Za-z\s'.-]/g, "").slice(0, 26));
  const onExpiry = (e: ChangeEvent<HTMLInputElement>) => setExpiry(formatExpiry(e.target.value));
  const onCvc = (e: ChangeEvent<HTMLInputElement>) =>
    setCvc(e.target.value.replace(/\D/g, "").slice(0, brand === "amex" ? 4 : 3));
  const onZip = (e: ChangeEvent<HTMLInputElement>) =>
    setZip(e.target.value.replace(/[^A-Za-z0-9 -]/g, "").slice(0, 10));

  const blur = (k: FieldKey) => setTouched((t) => ({ ...t, [k]: true }));

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    if (status === "processing") return;
    const eObj = getErrors({ number, name, expiry, cvc, zip });
    setTouched({ number: true, name: true, expiry: true, cvc: true, zip: true });
    if (Object.keys(eObj).length > 0) {
      const order: [FieldKey, RefObject<HTMLInputElement | null>][] = [
        ["number", numberRef],
        ["name", nameRef],
        ["expiry", expiryRef],
        ["cvc", cvcRef],
        ["zip", zipRef],
      ];
      for (const [key, ref] of order) {
        if (eObj[key]) {
          ref.current?.focus();
          break;
        }
      }
      return;
    }
    setStatus("processing");
    window.setTimeout(() => setStatus("success"), 1500);
  };

  const reset = () => {
    setNumber("");
    setName("");
    setExpiry("");
    setCvc("");
    setZip("");
    setTouched({});
    setStatus("idle");
    setCvcFocused(false);
  };

  const inputBase =
    "w-full rounded-xl border bg-white/80 px-3.5 py-3 text-[15px] font-medium text-neutral-900 placeholder:text-neutral-400 shadow-sm outline-none transition-colors duration-150 focus-visible:ring-2 focus-visible:ring-offset-1 focus-visible:ring-offset-white disabled:opacity-60 dark:bg-neutral-900/70 dark:text-neutral-50 dark:placeholder:text-neutral-500 dark:focus-visible:ring-offset-neutral-950";
  const fieldClass = (k: FieldKey): string =>
    show(k)
      ? `${inputBase} border-rose-400 focus-visible:ring-rose-400 dark:border-rose-500/70`
      : `${inputBase} border-neutral-200 focus-visible:border-indigo-500 focus-visible:ring-indigo-400 dark:border-neutral-700`;

  const spring = reduce
    ? { duration: 0 }
    : { type: "spring" as const, stiffness: 260, damping: 26 };
  const swap = reduce
    ? { duration: 0 }
    : { duration: 0.32, ease: "easeOut" as const };

  const cardName = name ? name.toUpperCase() : "YOUR NAME";

  return (
    <section className="relative w-full overflow-hidden bg-neutral-50 px-4 py-16 text-neutral-900 sm:py-24 dark:bg-neutral-950 dark:text-neutral-50">
      <style>{`
        @keyframes fpay-sheen { 0% { transform: translateX(-140%) skewX(-18deg); } 60%, 100% { transform: translateX(260%) skewX(-18deg); } }
        @keyframes fpay-spin { to { transform: rotate(360deg); } }
        @keyframes fpay-draw { to { stroke-dashoffset: 0; } }
        @keyframes fpay-pop { 0% { transform: scale(0.7); opacity: 0; } 60% { transform: scale(1.06); } 100% { transform: scale(1); opacity: 1; } }
        .fpay-sheen { animation: fpay-sheen 7s ease-in-out infinite; }
        .fpay-spin { animation: fpay-spin 0.7s linear infinite; }
        .fpay-check { stroke-dasharray: 40; stroke-dashoffset: 40; animation: fpay-draw 0.5s 0.15s ease forwards; }
        .fpay-pop { animation: fpay-pop 0.45s cubic-bezier(0.22,1,0.36,1) forwards; }
        @media (prefers-reduced-motion: reduce) {
          .fpay-sheen { animation: none; opacity: 0; }
          .fpay-spin { animation-duration: 1.5s; }
          .fpay-check { animation: none; stroke-dashoffset: 0; }
          .fpay-pop { animation: none; }
        }
      `}</style>

      {/* decorative backdrop */}
      <div aria-hidden className="pointer-events-none absolute inset-0 overflow-hidden">
        <div className="absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-300/30 blur-3xl dark:bg-indigo-600/20" />
        <div className="absolute -bottom-32 -right-16 h-80 w-80 rounded-full bg-violet-300/30 blur-3xl dark:bg-violet-700/20" />
      </div>

      <div className="relative mx-auto max-w-5xl">
        <div className="grid overflow-hidden rounded-3xl border border-neutral-200 bg-white shadow-2xl shadow-neutral-900/5 lg:grid-cols-[1fr_1.05fr] dark:border-neutral-800 dark:bg-neutral-900 dark:shadow-black/40">
          {/* ---- Left: summary + live card ---- */}
          <div className="relative flex flex-col gap-8 border-b border-neutral-200 bg-gradient-to-br from-neutral-50 to-neutral-100/60 p-7 sm:p-9 lg:border-b-0 lg:border-r dark:border-neutral-800 dark:from-neutral-900 dark:to-neutral-950">
            <div>
              <p className="text-xs font-semibold uppercase tracking-[0.18em] text-indigo-600 dark:text-indigo-400">
                Order summary
              </p>
              <h2 className="mt-2 text-2xl font-semibold tracking-tight">Aurora Pro</h2>
              <p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
                Billed annually &middot; renews Jul 16, 2027
              </p>
            </div>

            {/* live card preview */}
            <div
              aria-hidden
              className="[perspective:1400px]"
              style={{ perspective: "1400px" }}
            >
              <motion.div
                className="relative aspect-[86/54] w-full max-w-sm [transform-style:preserve-3d]"
                animate={{ rotateY: cvcFocused ? 180 : 0 }}
                transition={spring}
              >
                {/* front */}
                <div className="absolute inset-0 flex flex-col justify-between overflow-hidden rounded-2xl bg-gradient-to-br from-indigo-600 via-violet-600 to-indigo-800 p-5 text-white shadow-xl shadow-indigo-900/30 [backface-visibility:hidden]">
                  <div className="pointer-events-none absolute inset-0 opacity-40 fpay-sheen bg-gradient-to-r from-transparent via-white/40 to-transparent" />
                  <div className="pointer-events-none absolute -right-10 -top-10 h-40 w-40 rounded-full bg-white/10" />
                  <div className="relative flex items-start justify-between">
                    {/* chip */}
                    <div className="h-9 w-12 rounded-md bg-gradient-to-br from-amber-200 to-amber-400 shadow-inner">
                      <div className="mx-auto mt-1.5 h-6 w-8 rounded-sm border border-amber-500/40" />
                    </div>
                    <div className="min-h-[1.25rem] text-right">
                      <BrandMark brand={brand} className="h-5 text-base text-white" />
                    </div>
                  </div>
                  <div className="relative font-mono text-lg tracking-[0.12em] tabular-nums sm:text-xl">
                    {previewNumber(digits, brand)}
                  </div>
                  <div className="relative flex items-end justify-between gap-3">
                    <div className="min-w-0">
                      <p className="text-[9px] uppercase tracking-widest text-white/60">
                        Card holder
                      </p>
                      <p className="truncate text-sm font-medium tracking-wide">{cardName}</p>
                    </div>
                    <div className="text-right">
                      <p className="text-[9px] uppercase tracking-widest text-white/60">Expires</p>
                      <p className="font-mono text-sm tabular-nums">{expiry || "MM/YY"}</p>
                    </div>
                  </div>
                </div>
                {/* back */}
                <div className="absolute inset-0 flex flex-col overflow-hidden rounded-2xl bg-gradient-to-br from-neutral-800 to-neutral-900 text-white shadow-xl [backface-visibility:hidden] [transform:rotateY(180deg)]">
                  <div className="mt-5 h-10 w-full bg-black/80" />
                  <div className="px-5 pt-4">
                    <div className="flex items-center gap-3">
                      <div className="h-8 flex-1 rounded bg-white/85" />
                      <div className="rounded bg-white px-2 py-1 font-mono text-sm font-semibold tabular-nums text-neutral-900">
                        {(cvc || "•••").padEnd(brand === "amex" ? 4 : 3, "•")}
                      </div>
                    </div>
                    <p className="mt-3 text-[9px] uppercase tracking-widest text-white/50">
                      Security code
                    </p>
                  </div>
                </div>
              </motion.div>
            </div>

            {/* price lines */}
            <div className="mt-auto space-y-2.5 text-sm">
              <div className="flex justify-between text-neutral-600 dark:text-neutral-400">
                <span>Aurora Pro (annual)</span>
                <span className="tabular-nums">$276.00</span>
              </div>
              <div className="flex justify-between text-emerald-600 dark:text-emerald-400">
                <span>Launch discount</span>
                <span className="tabular-nums">&minus;$48.00</span>
              </div>
              <div className="mt-3 flex items-baseline justify-between border-t border-neutral-200 pt-3 dark:border-neutral-800">
                <span className="font-semibold">Total due today</span>
                <span className="text-xl font-semibold tabular-nums">${AMOUNT}</span>
              </div>
            </div>
          </div>

          {/* ---- Right: form / success ---- */}
          <div className="relative p-7 sm:p-9">
            <AnimatePresence mode="wait" initial={false}>
              {status === "success" ? (
                <motion.div
                  key="success"
                  initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0 }}
                  transition={swap}
                  className="flex min-h-[26rem] flex-col items-center justify-center text-center"
                  role="status"
                  aria-live="polite"
                >
                  <div className="fpay-pop flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-500/15">
                    <svg
                      viewBox="0 0 24 24"
                      className="h-8 w-8 text-emerald-500 dark:text-emerald-400"
                      fill="none"
                      aria-hidden
                    >
                      <path
                        className="fpay-check"
                        d="M5 12.5l4.2 4.2L19 7"
                        stroke="currentColor"
                        strokeWidth="2.4"
                        strokeLinecap="round"
                        strokeLinejoin="round"
                      />
                    </svg>
                  </div>
                  <h2 className="mt-6 text-2xl font-semibold tracking-tight">Payment confirmed</h2>
                  <p className="mt-2 max-w-xs text-sm text-neutral-500 dark:text-neutral-400">
                    We charged ${AMOUNT} to your {BRAND_LABEL[brand]} ending in{" "}
                    <span className="font-mono tabular-nums">{digits.slice(-4) || "0000"}</span>. A
                    receipt is on its way to your inbox.
                  </p>
                  <button
                    type="button"
                    onClick={reset}
                    className="mt-8 rounded-xl border border-neutral-200 bg-white px-5 py-2.5 text-sm font-semibold text-neutral-900 shadow-sm outline-none transition-colors hover:bg-neutral-50 focus-visible:ring-2 focus-visible:ring-indigo-400 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-50 dark:hover:bg-neutral-700"
                  >
                    Make another payment
                  </button>
                </motion.div>
              ) : (
                <motion.form
                  key="form"
                  noValidate
                  onSubmit={handleSubmit}
                  initial={{ opacity: 0, y: reduce ? 0 : 8 }}
                  animate={{ opacity: 1, y: 0 }}
                  exit={{ opacity: 0 }}
                  transition={swap}
                  aria-describedby={fid("secnote")}
                >
                  <div className="flex items-center justify-between">
                    <h1 className="text-lg font-semibold tracking-tight">Payment details</h1>
                    <div className="flex items-center gap-2 text-neutral-400 dark:text-neutral-500">
                      <BrandMark brand="visa" className="h-4 text-sm" />
                      <BrandMark brand="mastercard" className="h-4" />
                      <BrandMark brand="amex" className="h-4 text-[11px]" />
                    </div>
                  </div>

                  <span className="sr-only" aria-live="polite">
                    {brand === "unknown" ? "" : `Detected card: ${BRAND_LABEL[brand]}`}
                  </span>

                  <div className="mt-6 space-y-4">
                    {/* card number */}
                    <div>
                      <label
                        htmlFor={fid("number")}
                        className="mb-1.5 block text-sm font-medium text-neutral-700 dark:text-neutral-300"
                      >
                        Card number
                      </label>
                      <div className="relative">
                        <input
                          id={fid("number")}
                          ref={numberRef}
                          type="text"
                          inputMode="numeric"
                          autoComplete="cc-number"
                          placeholder="1234 5678 9012 3456"
                          value={number}
                          onChange={onNumber}
                          onBlur={() => blur("number")}
                          disabled={status === "processing"}
                          maxLength={brand === "amex" ? 17 : 19}
                          aria-invalid={show("number") ? true : undefined}
                          aria-describedby={show("number") ? fid("number-err") : undefined}
                          className={`${fieldClass("number")} pr-14 font-mono tracking-wide tabular-nums`}
                        />
                        <span className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 text-neutral-500 dark:text-neutral-300">
                          <BrandMark brand={brand} className="h-4 text-sm" />
                        </span>
                      </div>
                      {show("number") ? (
                        <p
                          id={fid("number-err")}
                          role="alert"
                          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                        >
                          {show("number")}
                        </p>
                      ) : null}
                    </div>

                    {/* name */}
                    <div>
                      <label
                        htmlFor={fid("name")}
                        className="mb-1.5 block text-sm font-medium text-neutral-700 dark:text-neutral-300"
                      >
                        Name on card
                      </label>
                      <input
                        id={fid("name")}
                        ref={nameRef}
                        type="text"
                        autoComplete="cc-name"
                        placeholder="Jordan A. Rivera"
                        value={name}
                        onChange={onName}
                        onBlur={() => blur("name")}
                        disabled={status === "processing"}
                        aria-invalid={show("name") ? true : undefined}
                        aria-describedby={show("name") ? fid("name-err") : undefined}
                        className={fieldClass("name")}
                      />
                      {show("name") ? (
                        <p
                          id={fid("name-err")}
                          role="alert"
                          className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                        >
                          {show("name")}
                        </p>
                      ) : null}
                    </div>

                    {/* expiry + cvc + zip */}
                    <div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
                      <div>
                        <label
                          htmlFor={fid("expiry")}
                          className="mb-1.5 block text-sm font-medium text-neutral-700 dark:text-neutral-300"
                        >
                          Expiry
                        </label>
                        <input
                          id={fid("expiry")}
                          ref={expiryRef}
                          type="text"
                          inputMode="numeric"
                          autoComplete="cc-exp"
                          placeholder="MM/YY"
                          value={expiry}
                          onChange={onExpiry}
                          onBlur={() => blur("expiry")}
                          disabled={status === "processing"}
                          maxLength={5}
                          aria-invalid={show("expiry") ? true : undefined}
                          aria-describedby={show("expiry") ? fid("expiry-err") : undefined}
                          className={`${fieldClass("expiry")} font-mono tabular-nums`}
                        />
                        {show("expiry") ? (
                          <p
                            id={fid("expiry-err")}
                            role="alert"
                            className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                          >
                            {show("expiry")}
                          </p>
                        ) : null}
                      </div>

                      <div>
                        <label
                          htmlFor={fid("cvc")}
                          className="mb-1.5 block text-sm font-medium text-neutral-700 dark:text-neutral-300"
                        >
                          CVC
                        </label>
                        <input
                          id={fid("cvc")}
                          ref={cvcRef}
                          type="text"
                          inputMode="numeric"
                          autoComplete="cc-csc"
                          placeholder={brand === "amex" ? "4 digits" : "3 digits"}
                          value={cvc}
                          onChange={onCvc}
                          onFocus={() => setCvcFocused(true)}
                          onBlur={() => {
                            setCvcFocused(false);
                            blur("cvc");
                          }}
                          disabled={status === "processing"}
                          maxLength={brand === "amex" ? 4 : 3}
                          aria-invalid={show("cvc") ? true : undefined}
                          aria-describedby={show("cvc") ? fid("cvc-err") : undefined}
                          className={`${fieldClass("cvc")} font-mono tabular-nums`}
                        />
                        {show("cvc") ? (
                          <p
                            id={fid("cvc-err")}
                            role="alert"
                            className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                          >
                            {show("cvc")}
                          </p>
                        ) : null}
                      </div>

                      <div className="col-span-2 sm:col-span-1">
                        <label
                          htmlFor={fid("zip")}
                          className="mb-1.5 block text-sm font-medium text-neutral-700 dark:text-neutral-300"
                        >
                          Billing ZIP
                        </label>
                        <input
                          id={fid("zip")}
                          ref={zipRef}
                          type="text"
                          autoComplete="postal-code"
                          placeholder="94107"
                          value={zip}
                          onChange={onZip}
                          onBlur={() => blur("zip")}
                          disabled={status === "processing"}
                          maxLength={10}
                          aria-invalid={show("zip") ? true : undefined}
                          aria-describedby={show("zip") ? fid("zip-err") : undefined}
                          className={`${fieldClass("zip")} tabular-nums`}
                        />
                        {show("zip") ? (
                          <p
                            id={fid("zip-err")}
                            role="alert"
                            className="mt-1.5 text-xs font-medium text-rose-600 dark:text-rose-400"
                          >
                            {show("zip")}
                          </p>
                        ) : null}
                      </div>
                    </div>
                  </div>

                  <button
                    type="submit"
                    disabled={status === "processing"}
                    className="group mt-7 flex w-full items-center justify-center gap-2.5 rounded-xl bg-gradient-to-r from-indigo-600 to-violet-600 px-5 py-3.5 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 outline-none transition-all duration-150 hover:from-indigo-500 hover:to-violet-500 hover:shadow-indigo-600/40 focus-visible:ring-2 focus-visible:ring-indigo-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-80 dark:focus-visible:ring-offset-neutral-900"
                  >
                    {status === "processing" ? (
                      <>
                        <span
                          aria-hidden
                          className="fpay-spin h-4 w-4 rounded-full border-2 border-white/40 border-t-white"
                        />
                        Processing&hellip;
                      </>
                    ) : (
                      <>
                        <svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" aria-hidden>
                          <rect
                            x="5"
                            y="11"
                            width="14"
                            height="9"
                            rx="2"
                            stroke="currentColor"
                            strokeWidth="1.8"
                          />
                          <path
                            d="M8 11V8a4 4 0 0 1 8 0v3"
                            stroke="currentColor"
                            strokeWidth="1.8"
                            strokeLinecap="round"
                          />
                        </svg>
                        Pay ${AMOUNT} securely
                      </>
                    )}
                  </button>

                  <p
                    id={fid("secnote")}
                    className="mt-4 flex items-center justify-center gap-1.5 text-center text-xs text-neutral-500 dark:text-neutral-400"
                  >
                    <svg viewBox="0 0 24 24" className="h-3.5 w-3.5 shrink-0" fill="none" aria-hidden>
                      <rect
                        x="5"
                        y="11"
                        width="14"
                        height="9"
                        rx="2"
                        stroke="currentColor"
                        strokeWidth="1.8"
                      />
                      <path
                        d="M8 11V8a4 4 0 0 1 8 0v3"
                        stroke="currentColor"
                        strokeWidth="1.8"
                        strokeLinecap="round"
                      />
                    </svg>
                    Secured with 256-bit TLS. Your card is encrypted and never stored on our
                    servers.
                  </p>
                </motion.form>
              )}
            </AnimatePresence>
          </div>
        </div>
      </div>
    </section>
  );
}

Dependencies

motion

Licence

Built by Web Innoventix. Free for personal and commercial use, no attribution required.

Built by Web Innoventix

Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.

Get a free quote

Similar components

Browse all →