A full card payment form — number, expiry, CVC, ZIP — with live network detection, per-brand grouping, Luhn validation, and inline field errors, à la Stripe Elements.
npx shadcn@latest add @paragon/payment-element"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CreditCard, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
type Brand = "visa" | "mastercard" | "amex" | "discover" | "generic";
interface BrandSpec {
gaps: number[];
length: number;
cvc: number;
}
const BRAND_SPECS: Record<Brand, BrandSpec> = {
visa: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
mastercard: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
discover: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
amex: { gaps: [4, 6, 5], length: 15, cvc: 4 },
generic: { gaps: [4, 4, 4, 4], length: 16, cvc: 3 },
};
function detectBrand(digits: string): Brand {
if (/^4/.test(digits)) return "visa";
if (/^(34|37)/.test(digits)) return "amex";
if (/^(5[1-5]|2[2-7])/.test(digits)) return "mastercard";
if (/^(6011|65|64[4-9])/.test(digits)) return "discover";
return "generic";
}
function groupDigits(digits: string, gaps: number[]) {
const out: string[] = [];
let i = 0;
for (const gap of gaps) {
if (i >= digits.length) break;
out.push(digits.slice(i, i + gap));
i += gap;
}
if (i < digits.length) out.push(digits.slice(i));
return out.join(" ");
}
/** Luhn check — the last line of validity on a card number. */
function luhnValid(digits: string) {
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
let d = digits.charCodeAt(i) - 48;
if (alt) {
d *= 2;
if (d > 9) d -= 9;
}
sum += d;
alt = !alt;
}
return sum % 10 === 0;
}
function BrandGlyph({ brand }: { brand: Brand }) {
const common = "block h-full w-full";
switch (brand) {
case "visa":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#1434CB" />
<path
d="M17.2 16.4 18.7 7.6h2.4l-1.5 8.8h-2.4Zm11-8.6c-.5-.2-1.2-.4-2.1-.4-2.3 0-4 1.2-4 3 0 1.3 1.2 2 2.1 2.4.9.4 1.2.7 1.2 1.1 0 .6-.7.8-1.4.8-.9 0-1.4-.1-2.2-.5l-.3-.1-.3 1.9c.6.2 1.6.5 2.6.5 2.5 0 4.1-1.2 4.1-3.1 0-1-.6-1.8-2-2.4-.8-.4-1.3-.7-1.3-1.1 0-.4.4-.7 1.3-.7.7 0 1.3.1 1.7.3l.2.1.3-1.8Zm5.9-.2h-1.9c-.6 0-1 .2-1.3.8l-3.6 8.2h2.5s.4-1.1.5-1.4h3.1c.1.3.3 1.4.3 1.4h2.2l-1.8-9Zm-3 5.9c.2-.5 1-2.5 1-2.5s.2-.5.3-.9l.2.8s.5 2.2.6 2.6h-2.1Zm-12.9-5.9-2.4 6-.3-1.3c-.4-1.5-1.9-3.2-3.5-4l2.2 8.2h2.5l3.8-8.9h-2.3Z"
fill="#fff"
/>
<path
d="M12.6 7.6H8.8l-.1.3c3 .7 5 2.5 5.8 4.6l-.8-4.1c-.1-.6-.5-.8-1.1-.8Z"
fill="#F7B600"
/>
</svg>
);
case "mastercard":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#252525" />
<circle cx="16" cy="12" r="7" fill="#EB001B" />
<circle cx="24" cy="12" r="7" fill="#F79E1B" />
<path d="M20 6.6a7 7 0 0 1 0 10.8 7 7 0 0 1 0-10.8Z" fill="#FF5F00" />
</svg>
);
case "amex":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#1F72CD" />
<text
x="20"
y="15"
textAnchor="middle"
fontSize="7"
fontWeight="700"
fill="#fff"
fontFamily="Arial, sans-serif"
>
AMEX
</text>
</svg>
);
case "discover":
return (
<svg viewBox="0 0 40 24" className={common} aria-hidden>
<rect width="40" height="24" rx="4" fill="#1a1a1a" />
<path d="M40 24H16c8-2 18-7 24-14v14Z" fill="#F76B1C" />
<circle cx="28" cy="12" r="4" fill="#F76B1C" />
</svg>
);
default:
return <CreditCard className="size-5 text-muted-foreground" aria-hidden />;
}
}
interface FieldState {
value: string;
error: string | null;
}
export interface PaymentElementValue {
number: string;
expiry: string;
cvc: string;
zip: string;
brand: Brand;
complete: boolean;
}
export interface PaymentElementProps extends React.ComponentProps<"form"> {
/** Fires on every keystroke with the current normalized value. */
onValueChange?: (value: PaymentElementValue) => void;
/** Fires on submit when every field validates. */
onPay?: (value: PaymentElementValue) => void;
/** Text on the pay button. */
payLabel?: string;
disabled?: boolean;
}
const inputBase =
"h-9 w-full bg-transparent text-sm tabular-nums outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50";
/**
* A full card payment form — number, expiry, CVC, ZIP — with live network
* detection and field-level validation à la Stripe Elements. The brand glyph
* blur-swaps in from the leading digits, the grouping mask adapts per brand
* (Amex 4-6-5 / 4-digit CVC), and each field validates on blur with an
* inline message that fades in below the fused input group. The number is
* Luhn-checked. Nothing is persisted — values live in local state and are
* surfaced through `onValueChange` / `onPay`.
*/
export function PaymentElement({
onValueChange,
onPay,
payLabel = "Pay",
disabled,
className,
...props
}: PaymentElementProps) {
const reduced = useReducedMotion();
const baseId = React.useId();
const [number, setNumber] = React.useState<FieldState>({ value: "", error: null });
const [expiry, setExpiry] = React.useState<FieldState>({ value: "", error: null });
const [cvc, setCvc] = React.useState<FieldState>({ value: "", error: null });
const [zip, setZip] = React.useState<FieldState>({ value: "", error: null });
const brand = detectBrand(number.value);
const spec = BRAND_SPECS[brand];
const complete =
number.value.length === spec.length &&
luhnValid(number.value) &&
/^\d{2}\/\d{2}$/.test(expiry.value) &&
cvc.value.length === spec.cvc &&
zip.value.length >= 5;
const changeRef = React.useRef(onValueChange);
changeRef.current = onValueChange;
React.useEffect(() => {
changeRef.current?.({
number: number.value,
expiry: expiry.value,
cvc: cvc.value,
zip: zip.value,
brand,
complete,
});
}, [number.value, expiry.value, cvc.value, zip.value, brand, complete]);
function handleNumber(e: React.ChangeEvent<HTMLInputElement>) {
const digits = e.target.value.replace(/\D/g, "").slice(0, spec.length);
setNumber({ value: digits, error: null });
setCvc((prev) => ({
...prev,
value: prev.value.slice(0, BRAND_SPECS[detectBrand(digits)].cvc),
}));
}
function handleExpiry(e: React.ChangeEvent<HTMLInputElement>) {
let digits = e.target.value.replace(/\D/g, "").slice(0, 4);
if (digits.length >= 3) digits = `${digits.slice(0, 2)}/${digits.slice(2)}`;
else if (digits.length === 2 && !expiry.value.endsWith("/"))
digits = `${digits}/`;
setExpiry({ value: digits, error: null });
}
function validateExpiry(value: string): string | null {
if (!/^\d{2}\/\d{2}$/.test(value)) return "Incomplete date";
const mm = Number(value.slice(0, 2));
if (mm < 1 || mm > 12) return "Invalid month";
return null;
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const nErr =
number.value.length !== spec.length
? "Incomplete number"
: luhnValid(number.value)
? null
: "Invalid card number";
const eErr = validateExpiry(expiry.value);
const cErr = cvc.value.length === spec.cvc ? null : "Incomplete code";
const zErr = zip.value.length >= 5 ? null : "Incomplete ZIP";
setNumber((s) => ({ ...s, error: nErr }));
setExpiry((s) => ({ ...s, error: eErr }));
setCvc((s) => ({ ...s, error: cErr }));
setZip((s) => ({ ...s, error: zErr }));
if (!nErr && !eErr && !cErr && !zErr) {
onPay?.({
number: number.value,
expiry: expiry.value,
cvc: cvc.value,
zip: zip.value,
brand,
complete: true,
});
}
}
const anyError =
number.error ?? expiry.error ?? cvc.error ?? zip.error ?? null;
return (
<form
onSubmit={handleSubmit}
className={cn("w-full max-w-sm", className)}
noValidate
{...props}
>
<label
htmlFor={`${baseId}-number`}
className="mb-1.5 block text-sm font-medium"
>
Card information
</label>
{/* Fused input group — Stripe's single bordered card block. */}
<div
className={cn(
"overflow-hidden rounded-lg border bg-card transition-[border-color,box-shadow] duration-150 ease-out",
"focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
anyError && "border-destructive focus-within:border-destructive",
)}
>
<div className="flex items-center gap-2 border-b px-3">
<input
id={`${baseId}-number`}
inputMode="numeric"
autoComplete="cc-number"
disabled={disabled}
placeholder="1234 1234 1234 1234"
value={groupDigits(number.value, spec.gaps)}
onChange={handleNumber}
onBlur={() =>
setNumber((s) => ({
...s,
error:
s.value.length === 0 || s.value.length === spec.length
? luhnValid(s.value) || s.value.length === 0
? null
: "Invalid card number"
: "Incomplete number",
}))
}
aria-label="Card number"
aria-invalid={!!number.error}
className={cn(inputBase, "flex-1")}
/>
<span className="flex h-6 w-9 shrink-0 items-center justify-center overflow-hidden rounded">
{reduced ? (
<BrandGlyph brand={brand} />
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={brand}
className="block h-6 w-9"
initial={{ opacity: 0, scale: 0.6, filter: "blur(4px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.6, filter: "blur(4px)" }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
<BrandGlyph brand={brand} />
</motion.span>
</AnimatePresence>
)}
</span>
</div>
<div className="grid grid-cols-2">
<input
inputMode="numeric"
autoComplete="cc-exp"
disabled={disabled}
placeholder="MM / YY"
value={expiry.value}
onChange={handleExpiry}
onBlur={() =>
setExpiry((s) => ({
...s,
error: s.value.length ? validateExpiry(s.value) : null,
}))
}
aria-label="Expiry date"
aria-invalid={!!expiry.error}
className={cn(inputBase, "border-r px-3")}
/>
<input
inputMode="numeric"
autoComplete="cc-csc"
disabled={disabled}
placeholder={spec.cvc === 4 ? "CVV" : "CVC"}
value={cvc.value}
onChange={(e) =>
setCvc({
value: e.target.value.replace(/\D/g, "").slice(0, spec.cvc),
error: null,
})
}
onBlur={() =>
setCvc((s) => ({
...s,
error:
s.value.length === 0 || s.value.length === spec.cvc
? null
: "Incomplete code",
}))
}
aria-label="Security code"
aria-invalid={!!cvc.error}
className={cn(inputBase, "px-3")}
/>
</div>
</div>
<label
htmlFor={`${baseId}-zip`}
className="mt-3 mb-1.5 block text-sm font-medium"
>
Billing ZIP
</label>
<input
id={`${baseId}-zip`}
inputMode="numeric"
autoComplete="postal-code"
disabled={disabled}
placeholder="90210"
value={zip.value}
onChange={(e) =>
setZip({ value: e.target.value.replace(/\D/g, "").slice(0, 5), error: null })
}
onBlur={() =>
setZip((s) => ({
...s,
error: s.value.length === 0 || s.value.length >= 5 ? null : "Incomplete ZIP",
}))
}
aria-label="Billing ZIP code"
aria-invalid={!!zip.error}
className={cn(
inputBase,
"rounded-lg border bg-card px-3 transition-[border-color,box-shadow] duration-150 ease-out",
"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
zip.error && "border-destructive",
)}
/>
<div
aria-live="polite"
className={cn(
"grid transition-[grid-template-rows,opacity] duration-200 ease-out motion-reduce:transition-none",
anyError ? "mt-2 grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0",
)}
>
<p className="overflow-hidden text-[13px] text-destructive">
{anyError}
</p>
</div>
<button
type="submit"
disabled={disabled}
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 disabled:pointer-events-none disabled:opacity-50"
>
<Lock className="size-3.5" aria-hidden />
{payLabel}
</button>
</form>
);
}