Fintech & Payments

Card Number Input

A card entry field whose network glyph blur-swaps in as digits identify the brand, with brand-aware grouping plus expiry and CVC fields. Never stores or logs values.

Install

npx shadcn@latest add @paragon/card-number-input

card-number-input.tsx

"use client";

import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { CreditCard } from "lucide-react";
import { cn } from "@/lib/utils";

type Brand = "visa" | "mastercard" | "amex" | "discover" | "generic";

interface BrandSpec {
  /** How the number is grouped, e.g. [4,4,4,4]. */
  gaps: number[];
  /** Total digit count (max) for this brand. */
  length: number;
  /** CVC digit count. */
  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 },
};

/** Identifies the network from the leading digits. */
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(" ");
}

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

export interface CardNumberInputProps {
  className?: string;
  /** Fires as fields change. Digits only for number/cvc; MM/YY for expiry. */
  onValueChange?: (value: {
    number: string;
    expiry: string;
    cvc: string;
    brand: Brand;
    complete: boolean;
  }) => void;
  disabled?: boolean;
}

/**
 * A card entry field where the network glyph blur-swaps in as soon as the
 * leading digits identify the brand (Visa, Mastercard, Amex, Discover). The
 * grouping mask adapts to the brand (Amex is 4-6-5, CVC becomes 4 digits).
 * Expiry and CVC share the row. Nothing is persisted or logged — values live
 * only in local state and are surfaced through `onValueChange`.
 */
export function CardNumberInput({
  className,
  onValueChange,
  disabled,
}: CardNumberInputProps) {
  const reduced = useReducedMotion();
  const baseId = React.useId();
  const [number, setNumber] = React.useState("");
  const [expiry, setExpiry] = React.useState("");
  const [cvc, setCvc] = React.useState("");

  const brand = detectBrand(number);
  const spec = BRAND_SPECS[brand];

  const complete =
    number.length === spec.length &&
    /^\d{2}\/\d{2}$/.test(expiry) &&
    cvc.length === spec.cvc;

  // Report changes without making onValueChange a render-time dependency.
  const changeRef = React.useRef(onValueChange);
  changeRef.current = onValueChange;
  React.useEffect(() => {
    changeRef.current?.({ number, expiry, cvc, brand, complete });
  }, [number, expiry, cvc, brand, complete]);

  function handleNumber(e: React.ChangeEvent<HTMLInputElement>) {
    const digits = e.target.value.replace(/\D/g, "").slice(0, spec.length);
    setNumber(digits);
    // If the CVC no longer fits the brand's width, trim it.
    setCvc((prev) => prev.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.endsWith("/")) digits = `${digits}/`;
    setExpiry(digits);
  }

  function handleCvc(e: React.ChangeEvent<HTMLInputElement>) {
    setCvc(e.target.value.replace(/\D/g, "").slice(0, spec.cvc));
  }

  return (
    <div className={cn("w-full max-w-sm", className)}>
      <label
        htmlFor={`${baseId}-number`}
        className="mb-1.5 block text-sm font-medium text-foreground"
      >
        Card number
      </label>
      <div
        className={cn(
          "relative flex h-9 items-center rounded-lg border border-input bg-transparent pl-3 pr-2 text-sm",
          "transition-[border-color,box-shadow] duration-150 ease-out",
          "focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/25",
          disabled && "cursor-not-allowed opacity-50",
        )}
      >
        <input
          id={`${baseId}-number`}
          inputMode="numeric"
          autoComplete="cc-number"
          disabled={disabled}
          placeholder="1234 1234 1234 1234"
          value={groupDigits(number, spec.gaps)}
          onChange={handleNumber}
          aria-label="Card number"
          className="w-full min-w-0 flex-1 bg-transparent tabular-nums outline-none placeholder:text-muted-foreground"
        />
        <span className="ml-2 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="mt-3 grid grid-cols-2 gap-3">
        <div>
          <label
            htmlFor={`${baseId}-expiry`}
            className="mb-1.5 block text-sm font-medium text-foreground"
          >
            Expiry
          </label>
          <input
            id={`${baseId}-expiry`}
            inputMode="numeric"
            autoComplete="cc-exp"
            disabled={disabled}
            placeholder="MM/YY"
            value={expiry}
            onChange={handleExpiry}
            aria-label="Expiry date"
            className={cn(
              "h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm tabular-nums outline-none",
              "transition-[border-color,box-shadow] duration-150 ease-out",
              "placeholder:text-muted-foreground",
              "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
              "disabled:cursor-not-allowed disabled:opacity-50",
            )}
          />
        </div>
        <div>
          <label
            htmlFor={`${baseId}-cvc`}
            className="mb-1.5 block text-sm font-medium text-foreground"
          >
            CVC
          </label>
          <input
            id={`${baseId}-cvc`}
            inputMode="numeric"
            autoComplete="cc-csc"
            disabled={disabled}
            placeholder={spec.cvc === 4 ? "1234" : "123"}
            value={cvc}
            onChange={handleCvc}
            aria-label="Security code"
            className={cn(
              "h-9 w-full rounded-lg border border-input bg-transparent px-3 text-sm tabular-nums outline-none",
              "transition-[border-color,box-shadow] duration-150 ease-out",
              "placeholder:text-muted-foreground",
              "focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
              "disabled:cursor-not-allowed disabled:opacity-50",
            )}
          />
        </div>
      </div>
    </div>
  );
}