Detecting Card Brand and Formatting Input

How do you show the right card logo after the first few digits, group the number into readable blocks as the user types — 4-4-4-4 for most cards, 4-6-5 for American Express — and do it without the caret leaping to the end every time the user fixes a digit in the middle? This recipe detects the brand from the issuer identification number (IIN) prefix, formats the value on input while preserving the caret by counting digits, rejects non-digit characters in beforeinput, and feeds the brand into the length and security-code rules of the wider payment card validation pipeline.

When to Use Live Brand Detection and Formatting

Formatting a card number as it is typed measurably reduces errors: grouped digits are far easier to compare against the physical card than a run of sixteen. Brand detection adds two practical benefits — a logo that reassures the user they are typing the right card, and the brand-specific rules (length, CVC digits) that later checks need. Use this recipe when:

  • You render your own card inputs rather than provider-hosted fields, which format for you.
  • Your audience types numbers manually rather than relying on wallets or autofill.
  • You accept multiple brands with different lengths, especially American Express.

Skip live formatting for fields where users paste long references they do not read, such as IBANs from banking apps — a formatter there adds risk for little gain. The IBAN recipe formats only on blur for that reason.

Card number grouping patterns by brand Cards showing how card numbers are grouped for display for each brand, including the four-six-five grouping of American Express. Visa, Mastercard, Discover 4 4 4 4 — e.g. 4242 4242 4242 4242 American Express 4 6 5 — e.g. 3782 822463 10005 Diners Club (14 digits) 4 6 4 — e.g. 3056 930902 5904 19-digit cards 4 4 4 4 3 — e.g. 6011 0009 9013 9424 123 Unknown brand 4-digit groups until the brand is known Pasted input reformatted once, caret placed at the end
Grouping follows the digits printed on the physical card, which is what users compare against while typing.

Minimal Working Brand Detection and Formatter

export type Brand = "visa" | "mastercard" | "amex" | "discover" | "diners" | "jcb" | "unknown";

interface BrandSpec { brand: Brand; match: RegExp; gaps: number[]; lengths: number[]; cvc: number; label: string; }

// Ordered: more specific prefixes first.
const SPECS: BrandSpec[] = [
  { brand: "amex", match: /^3[47]/, gaps: [4, 10], lengths: [15], cvc: 4, label: "American Express" },
  { brand: "diners", match: /^3(0[0-5]|[68])/, gaps: [4, 10], lengths: [14, 16, 19], cvc: 3, label: "Diners Club" },
  { brand: "jcb", match: /^35(2[89]|[3-8])/, gaps: [4, 8, 12], lengths: [16, 17, 18, 19], cvc: 3, label: "JCB" },
  { brand: "mastercard", match: /^(5[1-5]|2(2[2-9][1-9]|2[3-9]|[3-6]|7[01]|720))/, gaps: [4, 8, 12], lengths: [16], cvc: 3, label: "Mastercard" },
  { brand: "discover", match: /^(6011|65|64[4-9])/, gaps: [4, 8, 12, 16], lengths: [16, 17, 18, 19], cvc: 3, label: "Discover" },
  { brand: "visa", match: /^4/, gaps: [4, 8, 12, 16], lengths: [13, 16, 19], cvc: 3, label: "Visa" },
];

const UNKNOWN: BrandSpec = { brand: "unknown", match: /^/, gaps: [4, 8, 12, 16], lengths: [12, 13, 14, 15, 16, 17, 18, 19], cvc: 4, label: "Card" };

export const detect = (digits: string): BrandSpec => SPECS.find((s) => s.match.test(digits)) ?? UNKNOWN;

export function format(digits: string, spec: BrandSpec): string {
  let out = "";
  for (let i = 0; i < digits.length; i++) {
    if (spec.gaps.includes(i)) out += " ";
    out += digits[i];
  }
  return out;
}

const field = document.querySelector<HTMLInputElement>("#cc-number")!;
const brandText = document.querySelector<HTMLElement>("#cc-brand")!; // visually hidden, in aria-describedby
const csc = document.querySelector<HTMLInputElement>("#cc-csc")!;
let currentBrand: Brand = "unknown";

// 1. Refuse characters that can never be part of a card number, before they land.
field.addEventListener("beforeinput", (event) => {
  if (event.inputType.startsWith("insert") && event.data && /[^\d\s-]/.test(event.data)) {
    event.preventDefault();
  }
});

// 2. Reformat and restore the caret by counting digits to its left.
field.addEventListener("input", () => {
  const caret = field.selectionStart ?? field.value.length;
  const digitsBeforeCaret = field.value.slice(0, caret).replace(/\D/g, "").length;
  const spec = detect(field.value.replace(/\D/g, ""));
  const digits = field.value.replace(/\D/g, "").slice(0, Math.max(...spec.lengths));
  const formatted = format(digits, spec);

  if (formatted !== field.value) {
    field.value = formatted;
    // Walk the formatted string until we have passed the same number of digits.
    let pos = 0;
    for (let seen = 0; pos < formatted.length && seen < digitsBeforeCaret; pos++) {
      if (/\d/.test(formatted[pos])) seen++;
    }
    field.setSelectionRange(pos, pos);
  }

  // 3. Brand side effects — only when the brand actually changes.
  if (spec.brand !== currentBrand) {
    currentBrand = spec.brand;
    field.dataset.brand = spec.brand;
    brandText.textContent = spec.brand === "unknown" ? "" : `Detected card type: ${spec.label}`;
    csc.maxLength = spec.cvc;
    csc.placeholder = "•".repeat(spec.cvc);
  }
});

Counting digits before the caret, rather than characters, is the whole trick. Spaces come and go as the formatter runs, but the number of digits to the caret’s left is invariant, so mapping it back into the new string puts the caret exactly where the user expects — including after deleting a digit in the middle, which shifts every later group.

Caret-preserving format on input The user deletes a digit in the middle of a formatted number; the handler counts digits before the caret, reformats, and maps the digit count back to a caret position in the new string. User Input field Formatter Backspace in the middle of group 2 value "4242 424 4242 4242", caret 8 7 digits before the caret value "4242 4244 2424 242" setSelectionRange at 8 caret stays after the 7th digit
The digit count to the caret's left survives reformatting, so it is the reliable anchor for restoring the caret.

Formatter Option Reference

Option Type Default Purpose
SPECS[].match RegExp per brand IIN prefix test; order from most to least specific
SPECS[].gaps number[] [4, 8, 12] Digit indexes before which a space is inserted
SPECS[].lengths number[] per brand Maximum digits kept; also used by the length rule
SPECS[].cvc number 3 Security-code length for the neighbouring field
maxlength on the input number 23 19 digits plus 4 spaces
beforeinput filter RegExp /[^\d\s-]/ Characters refused before insertion
Brand announcement text “Detected card type: …” Read with the field; updated only on brand change

Verification Steps

Card field with detected brand A checkout form with the card number formatted as an American Express number, the brand shown in text, and the security code field switched to four digits. Payment details Card number 3782 822463 10005 1 ✓ Detected card type: American Express Expiry date (MM/YY) 04/29 Security code •••• 2 4 digits on the front of the card Pay £48.00 1 Brand text lives in a visually hidden span referenced by aria-describedby 2 maxLength and the hint switch to 4 digits when the brand is Amex 3 Formatting spaces are stripped before submission
Once 37 is typed the field regroups as 4-6-5, the description announces American Express, and the security code expects four digits.
import { test, expect } from "@playwright/test";

test("caret survives a mid-number deletion", async ({ page }) => {
  await page.goto("/checkout");
  const card = page.getByLabel("Card number");
  await card.pressSequentially("4242424242424242");
  await card.evaluate((el: HTMLInputElement) => el.setSelectionRange(6, 6)); // after "4242 4"
  await page.keyboard.press("Backspace");
  await expect(card).toHaveValue("4242 2424 2424 242");
  // Four digits sit before the caret, so it lands right after "4242".
  expect(await card.evaluate((el: HTMLInputElement) => el.selectionStart)).toBe(4);
});

Edge Cases and Failure Modes

Android keyboards and beforeinput. Some Android IMEs send composition events rather than discrete insertions, with event.data containing a whole word. The filter above still works because it tests every character in data, but never rely on keydown filtering: on those keyboards keydown reports key code 229 for everything.

Autofill bypasses the caret logic. Autofill sets the value with no caret context; selectionStart is often 0. That is harmless — the formatted value is set and the caret placement does not matter because the user is not typing — but make sure your formatter does not require a caret.

Submitting the formatted value. The server should receive digits only. Normalise in the submit handler or on the server; never make the server parse your display format.

form.addEventListener("formdata", (event) => {
  event.formData.set("cardnumber", field.value.replace(/\D/g, ""));
});

Brand flicker on the first digits. A leading 3 could be Amex, Diners or JCB. Show no logo until the prefix is unambiguous; flashing the wrong logo for one keystroke is worse than showing none.

Keeping the IIN Table Current

Card networks add ranges over time — Mastercard’s 2-series arrived in 2017, and eight-digit IINs are now standard — so any hard-coded table decays. Three habits keep that decay harmless. Treat “unknown” as a neutral state that allows any length from 12 to 19 and a four-digit CVC, so a new range is merely unbranded rather than rejected. Keep the table in one module with its own unit tests, so updating a range is a one-line, reviewed change. And never let the brand table be the reason a submission is blocked: the Luhn and length checks decide validity, while the brand only improves the display and the CVC hint. If your payment provider exposes the brand it detected in its tokenisation response, prefer that value for anything that matters after submission.

Formatting Without Losing Accessibility

Screen readers announce the input’s value when it changes through script, but they do so inconsistently: some read the whole new value, some only the inserted character, some nothing. Formatting adds spaces the user did not type, so a naive implementation that replaces the value on every keystroke can produce doubled announcements in some screen reader and browser pairings. Two habits keep it calm. First, only assign field.value when the formatted string actually differs, as the implementation does, so plain digit typing inside a group triggers no script-driven change at all. Second, never announce the formatted number through a live region; the brand is the only thing worth announcing, and only when it changes. If testing reveals a specific combination that still double-reads, the conservative fallback is to format on blur instead of input, keeping the brand detection live. The testing workflow for exactly this kind of check is in testing form errors with NVDA.

Frequently Asked Questions

How many digits do I need to detect a card brand?

Visa is identifiable from the first digit, American Express from two, Mastercard's 2-series needs four, and some Discover and JCB ranges need three or four. Show no brand until the prefix is unambiguous.

Why does the caret jump to the end when I format the card number?

Assigning input.value moves the caret to the end. Count the digits to the left of the caret before reformatting, then walk the new string to the same digit count and call setSelectionRange() there.

Should I block letters with keydown or beforeinput?

Use beforeinput. It reports the actual text being inserted, works with paste and IME composition, and can be cancelled. keydown is unreliable on Android keyboards, where it often reports key code 229.

Do I submit the card number with spaces?

No. Strip formatting before it reaches the server, for example in a formdata event listener, so the server only ever receives digits.

← Back to Payment Card Validation