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.
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.
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
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.
Related Guides
- Payment Card Validation — how brand detection feeds the length and CVC rules.
- Luhn Algorithm Credit Card Validation — the checksum run after formatting.
- Validating Card Expiry Dates — the MM/YY field next door, with its own formatter.
- Choosing inputmode and enterkeyhint — the right mobile keyboard for numeric fields.