Billing Payment Form
Billing Payment Form is a React and Tailwind CSS form component from HextaUI, licensed MIT. Copy it and paste it into your project.
HextaUI
MIT
forms
What it is
Billing Payment Form is a React and Tailwind CSS form component from HextaUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
No packages beyond the project baseline
Licence
This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Billing Payment Form
"use client";
import { CreditCard, Loader2, Lock, Shield } from "lucide-react";
import { useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/new-york/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/registry/new-york/ui/card";
import { Checkbox } from "@/registry/new-york/ui/checkbox";
import {
Field,
FieldContent,
FieldError,
FieldLabel,
} from "@/registry/new-york/ui/field";
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/registry/new-york/ui/input-group";
import { Separator } from "@/registry/new-york/ui/separator";
export interface PaymentFormData {
cardNumber: string;
expiryMonth: string;
expiryYear: string;
cvv: string;
holderName: string;
billingAddress?: {
line1: string;
line2?: string;
city: string;
state: string;
zip: string;
country: string;
};
saveForFuture?: boolean;
setAsDefault?: boolean;
}
export interface BillingPaymentFormProps {
onSubmit?: (data: PaymentFormData) => void;
onCancel?: () => void;
defaultValues?: Partial<PaymentFormData>;
className?: string;
isLoading?: boolean;
errors?: {
cardNumber?: string;
expiry?: string;
cvv?: string;
holderName?: string;
billingAddress?: Record<string, string>;
general?: string;
};
showBillingAddress?: boolean;
showSaveOption?: boolean;
showSetDefault?: boolean;
currency?: string;
}
function formatCardNumber(value: string): string {
const cleaned = value.replace(/\s+/g, "");
const chunks = cleaned.match(/.{1,4}/g) || [];
return chunks.join(" ").slice(0, 19);
}
function detectCardType(
cardNumber: string
): "visa" | "mastercard" | "amex" | "discover" | "other" {
const cleaned = cardNumber.replace(/\s+/g, "");
if (/^4/.test(cleaned)) return "visa";
if (/^5[1-5]/.test(cleaned)) return "mastercard";
if (/^3[47]/.test(cleaned)) return "amex";
if (/^6(?:011|5)/.test(cleaned)) return "discover";
return "other";
}
function getCardIcon(type: string): string {
switch (type) {
case "visa":
return "💳";
case "mastercard":
return "💳";
case "amex":
return "💳";
case "discover":
return "💳";
default:
return "💳";
}
}
export default function BillingPaymentForm({
onSubmit,
onCancel,
defaultValues,
className,
isLoading = false,
errors,
showBillingAddress = false,
showSaveOption = true,
showSetDefault = false,
}: BillingPaymentFormProps) {
const [cardNumber, setCardNumber] = useState(defaultValues?.cardNumber || "");
const [expiryMonth, setExpiryMonth] = useState(
defaultValues?.expiryMonth || ""
);
const [expiryYear, setExpiryYear] = useState(defaultValues?.expiryYear || "");
const [cvv, setCvv] = useState(defaultValues?.cvv || "");
const [holderName, setHolderName] = useState(defaultValues?.holderName || "");
const [showBilling, setShowBilling] = useState(showBillingAddress);
const [billingAddress, setBillingAddress] = useState(
defaultValues?.billingAddress || {
line1: "",
city: "",
state: "",
zip: "",
country: "",
}
);
const [saveForFuture, setSaveForFuture] = useState(
defaultValues?.saveForFuture ?? true
);
const [setAsDefault, setSetAsDefault] = useState(
defaultValues?.setAsDefault ?? false
);
const cardType = detectCardType(cardNumber);
const cardIcon = getCardIcon(cardType);
const handleCardNumberChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const formatted = formatCardNumber(e.target.value);
setCardNumber(formatted);
};
const handleExpiryChange = (
e: React.ChangeEvent<HTMLInputElement>,
type: "month" | "year"
) => {
const value = e.target.value.replace(/\D/g, "");
if (type === "month") {
const month = Math.min(12, Math.max(1, Number.parseInt(value) || 0));
setExpiryMonth(month.toString().padStart(2, "0").slice(0, 2));
} else {
setExpiryYear(value.slice(0, 2));
}
};
const handleCvvChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value.replace(/\D/g, "");
const maxLength = cardType === "amex" ? 4 : 3;
setCvv(value.slice(0, maxLength));
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSubmit?.({
cardNumber: cardNumber.replace(/\s+/g, ""),
expiryMonth,
expiryYear,
cvv,
holderName,
billingAddress: showBilling ? billingAddress : undefined,
saveForFuture,
setAsDefault,
});
};
const generalError = errors?.general;
return (
<Card className={cn("w-full shadow-xs", className)}>
<CardHeader>
<div className="flex flex-col gap-1">
<CardTitle className="flex items-center gap-2">
<CreditCard className="size-5" />
Payment method
</CardTitle>
<CardDescription>
Add a new payment method to your account
</CardDescription>
</div>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-6" onSubmit={handleSubmit}>
{generalError && (
<div
aria-live="polite"
className="rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-destructive text-sm"
role="alert"
>
{generalError}
</div>
)}
<Field data-invalid={!!errors?.cardNumber}>
<FieldLabel htmlFor="card-number">
Card number
<span aria-label="required" className="text-destructive">
*
</span>
</FieldLabel>
<FieldContent>
<InputGroup
aria-describedby={
errors?.cardNumber ? "card-number-error" : undefined
}
aria-invalid={!!errors?.cardNumber}
>
<InputGroupAddon>
<span aria-hidden="true" className="text-lg">
{cardIcon}
</span>
</InputGroupAddon>
<InputGroupInput
aria-describedby={
errors?.cardNumber ? "card-number-error" : undefined
}
aria-invalid={!!errors?.cardNumber}
id="card-number"
inputMode="numeric"
maxLength={19}
onChange={handleCardNumberChange}
placeholder="1234 5678 9012 3456"
type="text"
value={cardNumber}
/>
</InputGroup>
</FieldContent>
{errors?.cardNumber && (
<FieldError id="card-number-error">
{errors.cardNumber}
</FieldError>
)}
</Field>
<div className="grid grid-cols-2 gap-4">
<Field data-invalid={!!errors?.expiry}>
<FieldLabel>
Expiry date
<span aria-label="required" className="text-destructive">
*
</span>
</FieldLabel>
<FieldContent>
<div className="flex gap-2">
<div className="flex-1">
<label className="sr-only" htmlFor="expiry-month">
Expiry month
</label>
<InputGroup
aria-describedby={
errors?.expiry ? "expiry-error" : undefined
}
aria-invalid={!!errors?.expiry}
>
<InputGroupInput
aria-describedby={
errors?.expiry ? "expiry-error" : undefined
}
aria-invalid={!!errors?.expiry}
aria-label="Expiry month"
id="expiry-month"
inputMode="numeric"
maxLength={2}
onChange={(e) => handleExpiryChange(e, "month")}
placeholder="MM"
type="text"
value={expiryMonth}
/>
</InputGroup>
</div>
<div className="flex-1">
<label className="sr-only" htmlFor="expiry-year">
Expiry year
</label>
<InputGroup
aria-describedby={
errors?.expiry ? "expiry-error" : undefined
}
aria-invalid={!!errors?.expiry}
>
<InputGroupInput
aria-describedby={
errors?.expiry ? "expiry-error" : undefined
}
aria-invalid={!!errors?.expiry}
aria-label="Expiry year"
id="expiry-year"
inputMode="numeric"
maxLength={2}
onChange={(e) => handleExpiryChange(e, "year")}
placeholder="YY"
type="text"
value={expiryYear}
/>
</InputGroup>
</div>
</div>
</FieldContent>
{errors?.expiry && (
<FieldError id="expiry-error">{errors.expiry}</FieldError>
)}
</Field>
<Field data-invalid={!!errors?.cvv}>
<FieldLabel htmlFor="cvv">
CVV
<span aria-label="required" className="text-destructive">
*
</span>
</FieldLabel>
<FieldContent>
<InputGroup
aria-describedby={errors?.cvv ? "cvv-error" : undefined}
aria-invalid={!!errors?.cvv}
>
<InputGroupInput
aria-describedby={errors?.cvv ? "cvv-error" : undefined}
aria-invalid={!!errors?.cvv}
id="cvv"
inputMode="numeric"
maxLength={cardType === "amex" ? 4 : 3}
onChange={handleCvvChange}
placeholder={cardType === "amex" ? "1234" : "123"}
type="text"
value={cvv}
/>
</InputGroup>
</FieldContent>
{errors?.cvv && (
<FieldError id="cvv-error">{errors.cvv}</FieldError>
)}
</Field>
</div>
<Field data-invalid={!!errors?.holderName}>
<FieldLabel htmlFor="holder-name">
Cardholder name
<span aria-label="required" className="text-destructive">
*
</span>
</FieldLabel>
<FieldContent>
<InputGroup
aria-describedby={
errors?.holderName ? "holder-name-error" : undefined
}
aria-invalid={!!errors?.holderName}
>
<InputGroupInput
aria-describedby={
errors?.holderName ? "holder-name-error" : undefined
}
aria-invalid={!!errors?.holderName}
id="holder-name"
onChange={(e) => setHolderName(e.target.value)}
placeholder="John Doe"
type="text"
value={holderName}
/>
</InputGroup>
</FieldContent>
{errors?.holderName && (
<FieldError id="holder-name-error">
{errors.holderName}
</FieldError>
)}
</Field>
{showBillingAddress && (
<>
<Separator />
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">Billing address</h3>
<Button
onClick={() => setShowBilling(!showBilling)}
type="button"
variant="ghost"
>
{showBilling ? "Hide" : "Show"}
</Button>
</div>
{showBilling && (
<div className="flex flex-col gap-4">
<Field>
<FieldLabel htmlFor="billing-line1">
Street address
</FieldLabel>
<FieldContent>
<InputGroup>
<InputGroupInput
id="billing-line1"
onChange={(e) =>
setBillingAddress({
...billingAddress,
line1: e.target.value,
})
}
placeholder="123 Main St"
type="text"
value={billingAddress.line1}
/>
</InputGroup>
</FieldContent>
</Field>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="billing-city">City</FieldLabel>
<FieldContent>
<InputGroup>
<InputGroupInput
id="billing-city"
onChange={(e) =>
setBillingAddress({
...billingAddress,
city: e.target.value,
})
}
placeholder="New York"
type="text"
value={billingAddress.city}
/>
</InputGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="billing-state">State</FieldLabel>
<FieldContent>
<InputGroup>
<InputGroupInput
id="billing-state"
onChange={(e) =>
setBillingAddress({
...billingAddress,
state: e.target.value,
})
}
placeholder="NY"
type="text"
value={billingAddress.state}
/>
</InputGroup>
</FieldContent>
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field>
<FieldLabel htmlFor="billing-zip">ZIP code</FieldLabel>
<FieldContent>
<InputGroup>
<InputGroupInput
id="billing-zip"
onChange={(e) =>
setBillingAddress({
...billingAddress,
zip: e.target.value,
})
}
placeholder="10001"
type="text"
value={billingAddress.zip}
/>
</InputGroup>
</FieldContent>
</Field>
<Field>
<FieldLabel htmlFor="billing-country">
Country
</FieldLabel>
<FieldContent>
<InputGroup>
<InputGroupInput
id="billing-country"
onChange={(e) =>
setBillingAddress({
...billingAddress,
country: e.target.value,
})
}
placeholder="United States"
type="text"
value={billingAddress.country}
/>
</InputGroup>
</FieldContent>
</Field>
</div>
</div>
)}
</div>
</>
)}
<Separator />
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2 text-muted-foreground text-xs">
<Shield className="size-3.5" />
<span>Your payment information is encrypted and secure</span>
</div>
{showSaveOption && (
<div className="flex items-center gap-2">
<Checkbox
checked={saveForFuture}
id="save-for-future"
onCheckedChange={(checked) =>
setSaveForFuture(checked === true)
}
/>
<label className="text-sm" htmlFor="save-for-future">
Save this card for future payments
</label>
</div>
)}
{showSetDefault && (
<div className="flex items-center gap-2">
<Checkbox
checked={setAsDefault}
id="set-as-default"
onCheckedChange={(checked) =>
setSetAsDefault(checked === true)
}
/>
<label className="text-sm" htmlFor="set-as-default">
Set as default payment method
</label>
</div>
)}
</div>
<div className="flex flex-col gap-2 sm:flex-row">
{onCancel && (
<Button
className="w-full sm:w-auto"
onClick={onCancel}
type="button"
variant="outline"
>
Cancel
</Button>
)}
<Button
aria-busy={isLoading}
className="w-full sm:w-auto"
data-loading={isLoading}
type="submit"
>
{isLoading ? (
<>
<Loader2 className="size-4 animate-spin" />
Processing…
</>
) : (
<>
<Lock className="size-4" />
Add payment method
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}