Fintech & Payments

Payment Element

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.

Install

npx shadcn@latest add @paragon/payment-element

payment-element.tsx

"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>
  );
}