"use client"; /** * PaymentForm, from https://motion-brew.com/components/payment-form * * One self contained file: the component and the cn() * helper. Needs React 18 or 19 and Tailwind CSS v4. */ import * as React from "react"; import { useGSAP } from "@gsap/react"; import { type ClassValue, clsx } from "clsx"; import gsap from "gsap"; import { ArrowRight, CalendarDays, CreditCard, Plus, UserRound } from "lucide-react"; import { AnimatePresence, LayoutGroup, motion, useAnimationControls, useInView, useReducedMotion } from "motion/react"; import { twMerge } from "tailwind-merge"; // cn(): merges Tailwind classes, later ones win. /** Merge Tailwind classes, letting later classes win conflicts. */ function cn(...inputs: ClassValue[]): string { return twMerge(clsx(inputs)); } // PaymentForm /** * PaymentForm * * Trigger: type (plus focus and press). A white card for adding a payment * card. Every field is a soft gray pill with a label that floats up on focus, * and one ink focus ring glides from field to field as you move through the * form; each field's icon gives the same quick pop and tilt when it takes * focus. The card number groups itself as you type and the card network logo * rolls in when the number is recognised. The expiry adds its own slash. * Valid fields draw a light check; a field left wrong shakes once and rolls * in a hint. A spring switch saves the card. The button stays muted until the * form is complete, its lock shackle snapping shut when it is ready; a press * runs a spinner, then the button turns green and draws a check. Then the * whole card becomes a confirmation in the style of VerifyFlow: the green seal * spins in, draws its check and bursts sparks, a pill shows the card's logo, * last four and expiry, and Add another and Continue sit side by side. When * the card scrolls into view, the title, fields, switch and button rise out of * a soft blur one after another; Add another blurs the confirmation away and plays the * same rise back into a fresh form with the cursor in the number field. Errors and results are announced, and reduced motion * swaps the movement for fades. * * Engine: motion/react for the fields, focus ring, switch, button and * confirmation layout; gsap + @gsap/react for the seal (scoped with * useGSAP). */ gsap.registerPlugin(useGSAP); export interface PaymentDetails { number: string; name: string; expiry: string; cvc: string; brand: string | null; save: boolean; } export interface PaymentFormProps { /** Card title. Default `"Payment details"`. */ title?: string; /** Line under the title. */ subtitle?: string; /** Button label. Default `"Save card"`. */ cta?: string; /** Button label once saved. Default `"Card saved"`. */ doneLabel?: string; /** Whether the save switch starts on. Default `true`. */ defaultSave?: boolean; /** Confirmation button label. Default `"Continue to checkout"`. */ continueLabel?: string; /** Called by the confirmation's continue button. */ onContinue?: () => void; /** Called on submit. Return a promise to hold the spinner until it settles; a rejection returns to the form. */ onSubmit?: (details: PaymentDetails) => void | Promise; className?: string; } type Phase = "idle" | "loading" | "done"; type FieldId = "number" | "name" | "expiry" | "cvc"; const GLIDE = [0.22, 1, 0.36, 1] as const; const RING = { type: "spring", stiffness: 420, damping: 34 } as const; const POP = { type: "spring", stiffness: 520, damping: 22 } as const; const SUCCESS = "#269a5a"; /** The form's own states: rest, leaving for the confirmation, and coming back with its rows rising in one by one. */ const FORM = { hidden: {}, shown: { opacity: 1, scale: 1, filter: "blur(0px)", transition: { duration: 0.3, ease: GLIDE, staggerChildren: 0.06, delayChildren: 0.1, }, }, away: { opacity: 0, scale: 0.97, filter: "blur(6px)", transition: { duration: 0.35, ease: GLIDE, delay: 0.35 }, }, }; const ROW = { hidden: { opacity: 0, y: 14, filter: "blur(4px)" }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: 0.45, ease: GLIDE }, }, }; const ERROR = "#d6453d"; // --------------------------------------------------------------------------- // Card rules const digitsOf = (v: string) => v.replace(/\D/g, ""); function brandOf(digits: string) { if (/^4/.test(digits)) return "Visa"; if (/^(5[1-5]|2[2-7])/.test(digits)) return "Mastercard"; if (/^3[47]/.test(digits)) return "Amex"; if (/^6(011|5)/.test(digits)) return "Discover"; return null; } /** Groups of four, or 4 6 5 for Amex. */ function formatNumber(digits: string) { const amex = brandOf(digits) === "Amex"; const d = digits.slice(0, amex ? 15 : 19); if (amex) return [d.slice(0, 4), d.slice(4, 10), d.slice(10, 15)] .filter(Boolean) .join(" "); return d.replace(/(.{4})(?=.)/g, "$1 "); } function luhn(digits: string) { let sum = 0; for (let i = 0; i < digits.length; i++) { let n = Number(digits[digits.length - 1 - i]); if (i % 2) { n *= 2; if (n > 9) n -= 9; } sum += n; } return sum % 10 === 0; } function formatExpiry(raw: string) { let d = digitsOf(raw).slice(0, 4); // A first digit above 1 can only be a single digit month. if (d.length === 1 && Number(d) > 1) d = `0${d}`; return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d; } function expiryValid(v: string) { const m = /^(\d{2})\/(\d{2})$/.exec(v); if (!m) return false; const month = Number(m[1]); const year = 2000 + Number(m[2]); if (month < 1 || month > 12) return false; const now = new Date(); return ( year > now.getFullYear() || (year === now.getFullYear() && month >= now.getMonth() + 1) ); } // --------------------------------------------------------------------------- // Pieces type Brand = NonNullable>; /** Small card network marks, drawn inline so nothing loads at runtime. */ function BrandLogo({ brand }: { brand: Brand }) { const tile = "block h-6 w-9 overflow-hidden rounded-[6px] shadow-[0_0_0_1px_rgba(0,0,0,0.08),0_1px_2px_rgba(0,0,0,0.08)]"; const font = "ui-sans-serif, system-ui, -apple-system, Helvetica, Arial, sans-serif"; if (brand === "Mastercard") return ( ); if (brand === "Amex") return ( AMEX ); if (brand === "Discover") return ( DISC VER ); return ( VISA ); } /** A label that rolls up out of a blur whenever its text changes. */ function Rolling({ text, still }: { text: string; still: boolean }) { return ( {text} ); } /** The back of a card: stripe and code box. */ function CvcIcon() { return ( ); } /** A lock whose shackle lifts open while the form is incomplete and snaps shut once it is ready. */ function LockIcon({ shut, still }: { shut: boolean; still: boolean }) { return ( ); } interface FieldProps { id: FieldId; label: string; value: string; onChange: (v: string) => void; focused: FieldId | null; setFocused: (f: FieldId | null) => void; icon: React.ReactNode; valid: boolean; /** Message shown once the field has been left in a bad state. */ error: string; right?: React.ReactNode; disabled: boolean; still: boolean; inputProps?: React.InputHTMLAttributes; className?: string; uid: string; /** Room on the right for a badge as well as the check. */ wide?: boolean; } /** A soft gray well: the label floats on focus, the shared ring glides in, a check pops when valid and a bad blur shakes. */ function Field({ id, label, value, onChange, focused, setFocused, icon, valid, error, right, disabled, still, inputProps, className, uid, wide, }: FieldProps) { const [touched, setTouched] = React.useState(false); const shake = useAnimationControls(); const isFocused = focused === id; const floated = isFocused || value.length > 0; const showError = touched && !isFocused && !valid && value.length > 0; const inputId = `${uid}-${id}`; return ( {isFocused && ( )} {showError && ( )} {/* Every icon answers focus the same way: a quick pop with a small tilt. */} {icon} {label} onChange(e.target.value)} onFocus={() => setFocused(id)} onBlur={() => { setFocused(null); if (value.length) setTouched(true); if (value.length && !valid && !still) shake.start({ x: [0, -6, 6, -4, 4, 0], transition: { duration: 0.4 }, }); }} aria-invalid={showError || undefined} aria-describedby={showError ? `${inputId}-error` : undefined} className={cn( "absolute inset-0 w-full rounded-full bg-transparent pb-1.5 pl-[52px] pt-5 text-base tabular-nums text-zinc-900 outline-none placeholder:text-transparent disabled:cursor-not-allowed dark:text-zinc-100", wide ? "pr-32" : "pr-14", )} {...inputProps} /> {right} {valid && ( {/* A light check that draws itself in. */} )} {showError && ( {error} )} ); } // --------------------------------------------------------------------------- // Confirmation (the same seal as VerifyFlow) /** Scalloped seal outline, centred in a 100 by 100 box. */ function sealPath(bumps = 9, depth = 0.085, r = 42) { const pts: string[] = []; for (let i = 0; i <= 180; i++) { const t = (i / 180) * Math.PI * 2; const rr = r * (1 + depth * Math.cos(bumps * t)); pts.push( `${(50 + Math.cos(t) * rr).toFixed(2)} ${(50 + Math.sin(t) * rr).toFixed(2)}`, ); } return `M${pts.join(" L")}Z`; } const SEAL = sealPath(); /** Green seal that spins in, draws its check and bursts sparks, then floats. One GSAP timeline on mount, the same seal as VerifyFlow. */ function SuccessSeal({ accent, still }: { accent: string; still: boolean }) { const ref = React.useRef(null); useGSAP( () => { const q = (sel: string) => ref.current!.querySelectorAll(sel); if (still) { gsap.set(q(".seal-tick"), { strokeDashoffset: 0 }); return; } gsap .timeline({ delay: 0.6 }) .from(q(".seal"), { scale: 0, rotation: -60, svgOrigin: "60 60", duration: 0.7, ease: "back.out(1.9)", }) .to( q(".seal-tick"), { strokeDashoffset: 0, duration: 0.35, ease: "power2.out" }, 0.32, ) .fromTo( q(".spark"), { autoAlpha: 1, attr: { "stroke-dashoffset": 1 }, scale: 0.6, svgOrigin: "60 60", }, { attr: { "stroke-dashoffset": 0 }, scale: 1, duration: 0.35, ease: "power3.out", stagger: 0.02, }, 0.28, ) .to( q(".spark"), { autoAlpha: 0, scale: 1.18, svgOrigin: "60 60", duration: 0.45, ease: "power2.in", stagger: 0.02, }, 0.62, ) .fromTo( q(".dot"), { autoAlpha: 1, scale: 0.3, svgOrigin: "60 60" }, { scale: 1.35, autoAlpha: 0, duration: 0.9, ease: "power2.out", stagger: 0.03, }, 0.3, ) .to( q(".seal-wrap"), { y: -3, duration: 1.6, ease: "sine.inOut", repeat: -1, yoyo: true }, 1.1, ); }, { scope: ref }, ); const sparks = Array.from({ length: 8 }, (_, i) => { const a = (i / 8) * Math.PI * 2 - Math.PI / 2 + 0.2; return { x1: 60 + Math.cos(a) * 48, y1: 60 + Math.sin(a) * 48, x2: 60 + Math.cos(a) * 58, y2: 60 + Math.sin(a) * 58, }; }); const dots = Array.from({ length: 6 }, (_, i) => { const a = (i / 6) * Math.PI * 2 + 0.5; return { cx: 60 + Math.cos(a) * 54, cy: 60 + Math.sin(a) * 54 }; }); return ( {sparks.map((p, i) => ( ))} {dots.map((p, i) => ( ))} ); } /** The ink continue button; its arrow slides through on hover. */ function ContinueButton({ label, onClick, still, }: { label: string; onClick?: () => void; still: boolean; }) { const arrow = "absolute inset-0 size-4 transition-transform duration-[420ms] ease-[cubic-bezier(0.22,1,0.36,1)]"; return ( {label} ); } /** The whole card turns into the confirmation once the card is saved. */ function Confirmation({ brand, last4, expiry, title, cta, onContinue, onAnother, still, }: { brand: Brand | null; last4: string; expiry: string; title: string; cta: string; onContinue?: () => void; onAnother: () => void; still: boolean; }) { const heading = React.useRef(null); React.useEffect(() => heading.current?.focus(), []); return (

{title}

{brand && ( )} •••• {last4} · Expires {expiry}
Add another
); } // --------------------------------------------------------------------------- export const PaymentForm = React.forwardRef( function PaymentForm( { title = "Payment details", subtitle = "Add a card to finish setting up your plan. You can remove it any time.", cta = "Save card", doneLabel = "Card saved", defaultSave = true, onSubmit, continueLabel = "Continue to checkout", onContinue, className, }, ref, ) { const still = !!useReducedMotion(); const uid = React.useId(); const [focused, setFocused] = React.useState(null); const [number, setNumber] = React.useState(""); const [name, setName] = React.useState(""); const [expiry, setExpiry] = React.useState(""); const [cvc, setCvc] = React.useState(""); const [save, setSave] = React.useState(defaultSave); const [phase, setPhase] = React.useState("idle"); const [round, setRound] = React.useState(0); const form = React.useRef(null); const card = React.useRef(null); // The form plays in once the card scrolls into view, and again each time it comes back for another card. const inView = useInView(card, { once: true, amount: 0.3 }); const show = still || inView || round > 0; React.useImperativeHandle(ref, () => form.current as HTMLFormElement); React.useEffect(() => { if (form.current) form.current.inert = phase === "done"; }, [phase]); /** Clear everything for another card; the fields remount so their hints reset too. */ const another = () => { setNumber(""); setName(""); setExpiry(""); setCvc(""); setSave(defaultSave); setPhase("idle"); setRound((r) => r + 1); }; const digits = digitsOf(number); const brand = brandOf(digits); const amex = brand === "Amex"; // Full length for the brand (15 for Amex, 16 otherwise) and a passing Luhn check. const numberOk = digits.length === (amex ? 15 : 16) && luhn(digits); const nameOk = name.trim().length > 1; const expiryOk = expiryValid(expiry); const cvcOk = cvc.length === (amex ? 4 : 3); const ready = numberOk && nameOk && expiryOk && cvcOk; const locked = phase !== "idle"; const submit = async (e: React.FormEvent) => { e.preventDefault(); if (!ready || locked) return; setPhase("loading"); try { await Promise.all([ onSubmit?.({ number: digits, name: name.trim(), expiry, cvc, brand, save, }), new Promise((r) => setTimeout(r, still ? 200 : 1000)), ]); setPhase("done"); } catch { setPhase("idle"); } }; const muted = "#f4f4f5"; return (
{ // Coming back for another card: put the cursor in the number field. if (def === "shown" && round > 0) form.current?.querySelector("input")?.focus(); }} className="p-6" >

{title}

{subtitle}

setNumber(formatNumber(digitsOf(v)))} focused={focused} setFocused={setFocused} valid={numberOk} error="Check the card number." disabled={locked} still={still} className="col-span-2" icon={} right={ {brand && ( )} } inputProps={{ inputMode: "numeric", autoComplete: "cc-number", maxLength: 23, }} /> setName(v.replace(/[^\p{L}\s'.-]/gu, "").slice(0, 40)) } focused={focused} setFocused={setFocused} valid={nameOk} error="Enter the name on the card." disabled={locked} still={still} className="col-span-2" icon={} inputProps={{ autoComplete: "cc-name", autoCapitalize: "words", }} /> // Let backspace pass through the slash. setExpiry( v.length < expiry.length && expiry.endsWith("/") ? v.replace("/", "") : formatExpiry(v), ) } focused={focused} setFocused={setFocused} valid={expiryOk} error="Use a future date, MM/YY." disabled={locked} still={still} icon={} inputProps={{ inputMode: "numeric", autoComplete: "cc-exp", maxLength: 5, }} /> setCvc(digitsOf(v).slice(0, amex ? 4 : 3))} focused={focused} setFocused={setFocused} valid={cvcOk} error={ amex ? "Four digits on the front." : "Three digits on the back." } disabled={locked} still={still} icon={} inputProps={{ inputMode: "numeric", autoComplete: "cc-csc", maxLength: 4, }} />
{/* Save switch */} {phase === "loading" ? ( ) : phase === "done" ? ( ) : ( )}
{phase === "done" && ( )} {phase === "loading" ? "Saving card." : phase === "done" ? `${brand ?? "Card"} ending in ${digits.slice(-4)} saved.` : ""}
); }, );