UI Patterns
Input

Credit Card

Credit card number input with network detection, Luhn validation, and auto-formatting.

Examples

Card number with network detection

Detects the card network (Visa, Mastercard, Amex, etc.) from the first few digits and shows the brand icon on the left. The number is auto-formatted with spaces and validated using the Luhn algorithm.

Try: 4242 4242 4242 4242 (Visa), 5425 2334 3010 9903 (Mastercard), 3714 496353 98431 (Amex).

components/examples/credit-card-input.tsx
"use client";

import * as React from "react";
import { CreditCard } from "lucide-react";

import { cn } from "@/lib/utils";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";

// ---------------------------------------------------------------------------
// Card network detection
// ---------------------------------------------------------------------------

type CardNetwork =
  | "visa"
  | "mastercard"
  | "amex"
  | "discover"
  | "diners"
  | "jcb"
  | "unionpay"
  | null;

/**
 * Detect card network from the first digits (IIN / BIN range).
 * Works on partial input so the icon updates as the user types.
 */
function detectNetwork(digits: string): CardNetwork {
  if (digits.length === 0) return null;

  // Amex: 34, 37
  if (/^3[47]/.test(digits)) return "amex";
  // Discover: 6011, 622126–622925, 644–649, 65
  if (
    /^6011/.test(digits) ||
    /^65/.test(digits) ||
    /^64[4-9]/.test(digits) ||
    /^622(1[2-9][6-9]|[2-8]\d{2}|9[01]\d|92[0-5])/.test(digits)
  )
    return "discover";
  // Diners Club: 300–305, 36, 38
  if (/^3(0[0-5]|[68])/.test(digits)) return "diners";
  // JCB: 3528–3589
  if (/^35(2[89]|[3-8]\d)/.test(digits)) return "jcb";
  // UnionPay: 62
  if (/^62/.test(digits)) return "unionpay";
  // Mastercard: 51–55, 2221–2720
  if (
    /^5[1-5]/.test(digits) ||
    /^2(22[1-9]|2[3-9]\d|[3-6]\d{2}|7[01]\d|720)/.test(digits)
  )
    return "mastercard";
  // Visa: 4
  if (/^4/.test(digits)) return "visa";

  return null;
}

const NETWORK_LABELS: Record<Exclude<CardNetwork, null>, string> = {
  visa: "Visa",
  mastercard: "Mastercard",
  amex: "American Express",
  discover: "Discover",
  diners: "Diners Club",
  jcb: "JCB",
  unionpay: "UnionPay",
};

/** Expected digit count per network. */
function expectedLength(network: CardNetwork): number {
  if (network === "amex") return 15;
  if (network === "diners") return 14;
  return 16;
}

// ---------------------------------------------------------------------------
// Luhn checksum
// ---------------------------------------------------------------------------

function luhn(digits: string): boolean {
  if (!/^\d+$/.test(digits) || digits.length < 13) return false;

  let sum = 0;
  let double = false;
  for (let i = digits.length - 1; i >= 0; i--) {
    let d = Number(digits[i]);
    if (double) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
    double = !double;
  }
  return sum % 10 === 0;
}

// ---------------------------------------------------------------------------
// Formatting
// ---------------------------------------------------------------------------

/** Format with spaces: 4-6-5 for Amex, 4-4-4-4(+) for everything else. */
function formatCardNumber(digits: string, network: CardNetwork): string {
  if (network === "amex") {
    // 4-6-5
    const parts = [
      digits.slice(0, 4),
      digits.slice(4, 10),
      digits.slice(10, 15),
    ];
    return parts.filter(Boolean).join(" ");
  }
  // 4-4-4-4(+)
  const parts: string[] = [];
  for (let i = 0; i < digits.length; i += 4) {
    parts.push(digits.slice(i, i + 4));
  }
  return parts.join(" ");
}

// ---------------------------------------------------------------------------
// Card network icons (inline SVG — no external assets)
// ---------------------------------------------------------------------------

function CardIcon({ network }: { network: CardNetwork }) {
  if (!network) {
    return (
      <CreditCard className="text-muted-foreground size-5" aria-hidden="true" />
    );
  }

  // Each icon is a 34×24 rounded-rect card shape with a brand mark inside.
  const card = (bg: string, children: React.ReactNode) => (
    <svg
      viewBox="0 0 34 24"
      className="size-5"
      role="img"
      aria-label={NETWORK_LABELS[network]}
    >
      <rect width="34" height="24" rx="3" fill={bg} />
      {children}
    </svg>
  );

  switch (network) {
    case "visa":
      return card(
        "#1a1f71",
        <text
          x="17"
          y="15.5"
          textAnchor="middle"
          fill="#fff"
          fontSize="10"
          fontWeight="bold"
          fontStyle="italic"
          fontFamily="sans-serif"
        >
          VISA
        </text>,
      );
    case "mastercard":
      return card(
        "#252525",
        <>
          <circle cx="13" cy="12" r="7" fill="#eb001b" />
          <circle cx="21" cy="12" r="7" fill="#f79e1b" />
          <path
            d="M17 6.27a7 7 0 0 1 0 11.46A7 7 0 0 1 17 6.27Z"
            fill="#ff5f00"
          />
        </>,
      );
    case "amex":
      return card(
        "#2e77bc",
        <text
          x="17"
          y="15.5"
          textAnchor="middle"
          fill="#fff"
          fontSize="7"
          fontWeight="bold"
          fontFamily="sans-serif"
        >
          AMEX
        </text>,
      );
    case "discover":
      return card(
        "#fff",
        <>
          <rect
            width="34"
            height="24"
            rx="3"
            fill="#fff"
            stroke="#e5e7eb"
            strokeWidth="0.5"
          />
          <text
            x="17"
            y="15"
            textAnchor="middle"
            fill="#f60"
            fontSize="6.5"
            fontWeight="bold"
            fontFamily="sans-serif"
          >
            DISC
          </text>
        </>,
      );
    case "diners":
      return card(
        "#0079be",
        <text
          x="17"
          y="15.5"
          textAnchor="middle"
          fill="#fff"
          fontSize="6"
          fontWeight="bold"
          fontFamily="sans-serif"
        >
          DC
        </text>,
      );
    case "jcb":
      return card(
        "#0e4c96",
        <text
          x="17"
          y="15.5"
          textAnchor="middle"
          fill="#fff"
          fontSize="8"
          fontWeight="bold"
          fontFamily="sans-serif"
        >
          JCB
        </text>,
      );
    case "unionpay":
      return card(
        "#e21836",
        <text
          x="17"
          y="15.5"
          textAnchor="middle"
          fill="#fff"
          fontSize="5.5"
          fontWeight="bold"
          fontFamily="sans-serif"
        >
          UP
        </text>,
      );
  }
}

// ---------------------------------------------------------------------------
// Demo component
// ---------------------------------------------------------------------------

export function CreditCardInputDemo() {
  const id = React.useId();
  const feedbackId = `${id}-feedback`;
  const [raw, setRaw] = React.useState("");

  const digits = raw.replace(/\D/g, "");
  const network = React.useMemo(() => detectNetwork(digits), [digits]);
  const formatted = React.useMemo(
    () => formatCardNumber(digits, network),
    [digits, network],
  );

  const maxDigits = network === "amex" ? 15 : network === "diners" ? 14 : 19;
  const complete = digits.length >= expectedLength(network);
  const valid = complete && luhn(digits);

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    // Extract only digits from whatever the user types or pastes.
    const next = e.target.value.replace(/\D/g, "").slice(0, maxDigits);
    setRaw(next);
  }

  return (
    <div className="grid w-full gap-2">
      <Label htmlFor={id}>Card number</Label>
      <div className="relative">
        <div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
          <CardIcon network={network} />
        </div>
        <Input
          id={id}
          type="text"
          inputMode="numeric"
          autoComplete="cc-number"
          value={formatted}
          onChange={handleChange}
          placeholder="1234 5678 9012 3456"
          aria-describedby={complete ? feedbackId : undefined}
          aria-invalid={complete ? !valid : undefined}
          className="pl-10"
        />
      </div>
      <p
        id={feedbackId}
        aria-live="polite"
        className={cn(
          "min-h-4 text-xs",
          complete && valid
            ? "text-emerald-600 dark:text-emerald-400"
            : "text-muted-foreground",
        )}
      >
        {complete
          ? valid
            ? `Valid ${network ? NETWORK_LABELS[network] : "card"} number`
            : "Invalid card number"
          : network
            ? NETWORK_LABELS[network]
            : null}
      </p>
    </div>
  );
}

Usage:

const digits = raw.replace(/\D/g, "");
const network = detectNetwork(digits); // "visa" | "mastercard" | …
const isValid = luhn(digits); // true if checksum passes
const display = formatCardNumber(digits, network); // "4242 4242 4242 4242"

Notes:

  • Network detection from IIN prefixes. The first 1–6 digits (Issuer Identification Number) identify the card network. Detection runs on every keystroke against partial input, so the icon updates as soon as the first digit is typed.
  • Supported networks. Visa, Mastercard, American Express, Discover, Diners Club, JCB, and UnionPay. The IIN ranges are from the ISO/IEC 7812 specification.
  • Luhn algorithm. The standard mod-10 checksum used by all major card networks. It catches single-digit errors and most transpositions of adjacent digits — exactly the mistakes people make when typing a 16-digit number.
  • Auto-formatting. Spaces are inserted automatically: 4-6-5 grouping for Amex (matching the physical card), 4-4-4-4 for everything else. The input stores only digits internally and strips non-digits on every change, so paste works regardless of source formatting.
  • Icon on the left. The card brand icons are inline SVGs — no image assets to load. Each is a 34×24 rounded rect with a minimal brand mark (Mastercard gets the overlapping circles, others get text). Before a network is detected, a generic CreditCard icon from lucide-react is shown.
  • inputMode="numeric" shows the numeric keyboard on mobile. autoComplete="cc-number" lets browsers and password managers autofill the card number.
  • Validation is a hint. Like the barcode input, the Luhn check is informational — it does not prevent submission. Server-side validation and the payment processor are the real gatekeepers.
  • Cursor position. This implementation replaces the full value on each keystroke (controlled input with formatting). This means the cursor jumps to the end after editing mid-string. For production use, consider a library like react-number-format which preserves cursor position across reformats.

Further reading

On this page