Payment Form
A modern payment form component with animated card input fields, validation, and smooth transitions.
Loading…
Utils.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Packages
npm i motion lucide-react
npx shadcn@latest add button'use client';
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { ArrowRight, Calendar, CreditCard, Gift, Lock } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
export interface finalTextProps {
text: string;
}
export interface PaymentCardProps {
title: string;
description: string;
price: string;
feature?: string;
featuredescription?: string;
feature2?: string;
feature2description?: string;
finalText?:finalTextProps[];
onPay?: (data: { cardNumber: string; expiry: string; cvc: string }) => Promise<void> | void;
className?: string;
}
export function PaymentCard({ title, description, price, feature, featuredescription, feature2, feature2description, finalText = [], onPay, className }: PaymentCardProps) {
const [cardNumber, setCardNumber] = useState("");
const [expiry, setExpiry] = useState("");
const [cvc, setCvc] = useState("");
const [index, setIndex] = useState(0);
const [errors, setErrors] = useState<{ card?: string; expiry?: string; cvc?: string }>({});
const validate = () => {
const newErrors: typeof errors = {};
// Card number validation
if (!/^[0-9 ]{16,19}$/.test(cardNumber)) {
newErrors.card = "Card number must be 16 digits and only numbers.";
}
if (!/^(0[1-9]|1[0-2])\/\d{2}$/.test(expiry)) {
newErrors.expiry = "Enter a valid expiry date (MM/YY).";
} else {
const [month, year] = expiry.split("/").map(Number);
const now = new Date();
const expDate = new Date(2000 + year, month - 1);
if (expDate < now) {
newErrors.expiry = "Expiry date cannot be in the past.";
}
}
// CVC validation
if (!/^[0-9]{3,4}$/.test(cvc)) {
newErrors.cvc = "CVC must be 3 or 4 digits.";
}
setErrors(newErrors);
if(Object.keys(newErrors).length === 0){
return true;
}
return false;
};
const handlePay = ()=>{
if (validate()) {
if(onPay){
onPay({ cardNumber, expiry, cvc });
console.log("Payment processed!");
}
else{
console.log("error");
}
}
};
useEffect(() => {
if (!finalText || finalText.length === 0) return;
const interval = setInterval(() => {
setIndex((prev) => (prev + 1) % finalText.length)
}, 2000)
return () => clearInterval(interval)
}, [finalText])
return (
<div
className={cn(
"w-full max-w-4xl px-1 md:px-5 md:py-3 bg-background flex flex-col md:flex-row overflow-hidden md:gap-1 gap-2 pt-1",
className
)}
>
{/* Right: Summary */}
<div className="order-1 md:order-2 w-full md:max-w-[350px]
py-2 px-2 sm:py-3 md:py-6 h-fit my-auto md:px-4 md:w-1/2
rounded-lg bg-gradient-to-br from-primary/5 via-background to-primary/5 border border-border/50
flex flex-col gap-4 md:gap-6">
<div className=" px-2 py-1">
<h2 className="text-lg md:text-xl font-mono text-foreground">You've to pay</h2>
<p className="text-2xl md:text-3xl lg:text-4xl font-mono text-foreground mt-1">
${price || "320"}
<span className="text-sm font-mono text-muted-foreground">.00</span>
</p>
</div>
<div className="flex flex-col gap-3 md:gap-4 mt-4 px-2">
<div>
<h3 className="flex items-center gap-2 text-foreground text-sm md:text-base font-semibold">
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<motion.circle
cx="12"
cy="12"
r="10"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.6 }}
/>
<motion.path
d="M9 12l2 2 4-4"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.6, delay: 0.6 }}
/>
</motion.svg>
{feature || "Payment & Invoice"}
</h3>
<p className="text-xs md:text-sm mt-2 leading-relaxed text-muted-foreground">
{featuredescription || "Automated billing and invoicing with detailed transaction records. Professional receipts delivered instantly to your email."}
</p>
</div>
<div>
<h3 className="flex items-center gap-2 text-foreground text-sm md:text-base font-semibold">
<motion.svg
xmlns="http://www.w3.org/2000/svg"
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-primary"
>
<motion.circle
cx="12"
cy="12"
r="10"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.6 }}
/>
<motion.path
d="M9 12l2 2 4-4"
initial={{ pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.6, delay: 0.6 }}
/>
</motion.svg>
{feature2 || "Priority Support"}
</h3>
<p className="text-xs md:text-sm mt-2 leading-relaxed text-muted-foreground">
{feature2description || "Get dedicated customer support with faster response times and direct access to our technical team for any issues."}
</p>
</div>
</div>
<div className=" rounded-lg py-2 md:px-1 md:py-4 text-muted-foreground md:mt-2 flex justify-center items-center overflow-hidden ">
<AnimatePresence mode="wait">
<motion.p
key={index}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.6 }}
className="text-xs sm:text-sm text-center leading-relaxed absolute text-center overflow-hidden"
>
{finalText[index].text}
</motion.p>
</AnimatePresence>
</div>
</div>
{/* Left: Payment Form */}
<div className="order-2 md:order-1 w-full py-6 px-4 sm:py-8 sm:px-6 lg:px-10 flex flex-col gap-6 md:flex-1">
<div className="flex flex-col gap-6 w-full">
<div className="flex flex-col gap-2">
<h2 className="text-foreground text-xl sm:text-2xl font-semibold">
{title || "Final step, make the payment."}
</h2>
<p className="text-muted-foreground text-sm sm:text-base leading-relaxed">
{description ||"To finalize your subscription, kindly complete your payment using a valid credit card."}
</p>
</div>
{/* Card Number */}
<div className="flex flex-col gap-3">
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
<CreditCard className="w-4 h-4 text-muted-foreground" /> Card number
</label>
<div className="relative">
<div className="flex items-center rounded-lg border border-border bg-card px-4 py-3 w-full transition-colors hover:border-muted-foreground/50 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring">
{/* Animated Logos */}
<div className="relative w-16 h-6 overflow-hidden flex items-center">
<AnimatePresence mode="wait">
<motion.img
key={index}
src={[
"https://img.icons8.com/color/48/visa.png",
"https://img.icons8.com/color/48/mastercard-logo.png",
"https://img.icons8.com/color/48/amex.png",
"https://img.icons8.com/color/48/rupay.png",
][index % 4]}
alt="card"
className="w-6 h-6"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.5 }}
/>
</AnimatePresence>
</div>
{/* Input */}
<input
value={cardNumber}
onChange={(e) => setCardNumber(e.target.value)}
className="flex-1 bg-transparent outline-none text-sm placeholder:text-muted-foreground"
placeholder="1234 5678 9012 3456"
maxLength={19}
/>
</div>
{errors.card && (
<p className="text-xs text-destructive mt-1">{errors.card}</p>
)}
</div>
</div>
<div className="flex gap-6 items-start">
<div className="flex flex-col gap-1 w-fit">
<label className="text-xs sm:text-sm font-semibold text-foreground flex items-center gap-1 sm:gap-2">
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 text-muted-foreground" /> Expiry
</label>
<div className="relative w-20 pt-2">
<input
value={expiry}
onChange={(e) => setExpiry(e.target.value)}
className="w-full rounded-md border border-border bg-card px-2 py-2 sm:px-3 sm:py-2.5 text-xs sm:text-sm text-center placeholder:text-muted-foreground transition-colors hover:border-muted-foreground/50 focus:border-ring focus:ring-1 focus:ring-ring focus:outline-none"
placeholder="MM/YY"
maxLength={5}
/>
{errors.expiry && (
<p className="text-xs text-destructive mt-1">{errors.expiry}</p>
)}
</div>
</div>
<div className="flex flex-col gap-1 w-fit">
<label className="text-xs sm:text-sm font-semibold text-foreground flex items-center gap-1 sm:gap-2">
<Lock className="w-3 h-3 sm:w-4 sm:h-4 text-muted-foreground" /> CVC
</label>
<div className="relative w-20 pt-2">
<input
value={cvc}
onChange={(e) => setCvc(e.target.value)}
className="w-full rounded-md border border-border bg-card px-2 py-2 sm:px-3 sm:py-2.5 text-xs sm:text-sm text-center placeholder:text-muted-foreground transition-colors hover:border-muted-foreground/50 focus:border-ring focus:ring-1 focus:ring-ring focus:outline-none"
placeholder="123"
maxLength={4}
/>
{errors.cvc && (
<p className="text-xs text-destructive mt-1">{errors.cvc}</p>
)}
</div>
</div>
</div>
<div className="flex flex-col gap-3">
<label className="text-sm font-semibold text-foreground flex items-center gap-2">
<Gift className="w-4 h-4 text-muted-foreground" /> Discount code
</label>
<div className="flex items-center sm:flex-row gap-3">
<input
className="flex-1 rounded-lg border border-border bg-card px-4 py-3 text-sm placeholder:text-muted-foreground transition-colors hover:border-muted-foreground/50 focus:border-ring focus:ring-1 focus:ring-ring focus:outline-none"
placeholder="Enter discount code"
maxLength={12}
/>
<Button variant="outline" size="default" className="px-3 whitespace-nowrap">
Apply
</Button>
</div>
</div>
<Button
variant="default"
className="mt-6 w-full sm:w-auto px-8 py-3 text-base font-medium"
onClick={handlePay}
>
Pay now
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
</div>
</div>
</div>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
title | string | - | The main title displayed at the top of the payment form. |
description | string | - | A description text shown below the title. |
price | string | - | The price amount to be displayed. |
feature | string | - | Optional first feature title to display. |
featuredescription | string | - | Optional description for the first feature. |
feature2 | string | - | Optional second feature title to display. |
feature2description | string | - | Optional description for the second feature. |
finalText | Array<{ text: string }> | [] | Array of text items to display in the final section. |
onPay | (data: { cardNumber: string; expiry: string; cvc: string }) => Promise<void> | void | - | Callback function called when payment is submitted. Receives card details. |
className | string | - | Optional additional CSS classes to apply to the component. |