Credit Card Input in React: Spacing, Brand and Luhn

7 min read

TL;DR Use type="text" with inputMode="numeric" and autoComplete="cc-number". Strip non-digits, detect the brand from the first digits (Mastercard includes 2221 to 2720), group 4 4 4 4 or 4 6 5 for Amex, and keep the caret by counting digits before it, not characters. Run Luhn and the MM/YY check on blur. For real payments, let Stripe Elements collect the number.

On this page
  1. Why type="number" is the wrong input
  2. Brand, grouping and length from the first digits
  3. Keep the caret where the user is typing
  4. Using it in a Next.js page
  5. What to check in AI generated card inputs
  6. Making it look like a card
  7. FAQ
  8. How do I format a credit card number with spaces in React?
  9. How do I detect the card brand from the number?
  10. Does the Luhn check mean the card is valid?
  11. Should I build my own card inputs for real payments?

A credit card input in React needs four small jobs done right: keep only digits, group them the way the card prints them (4 4 4 4 for Visa, 4 6 5 for American Express), keep the caret where the user was typing, and run the Luhn check before you show an error. None of this needs a library. It's under 100 lines of plain TypeScript, and the hard part is the caret.

I make the Credit Card Checkout piece for qodesign, a 3D card that fills in as you type and flips for the CVC. The piece uses Framer Motion for the card. The code in this post uses no library at all: just React, Next.js and Tailwind. All three files below compile with strict tsc against the React 19 and Next 15 types in our own app, and I ran the helper functions against Stripe's published test numbers.

One thing before the code. If you are taking real payments, card numbers should not touch your inputs or your server. Let Stripe collect them inside its own iframes and keep your design around them. I wrote that up in Stripe Elements with a custom card design. What follows is for the demo state, tests, a design system, or the fields you show before you hand over to a payment provider. The rules for spacing, brand and checksum are the same either way.

Why type="number" is the wrong input

A card number is a string of digits, not a number. type="number" gives you a spinner, lets people type e and -, and won't let you put spaces in the value. Use a text input and ask for the number pad instead:

  • type="text" so you can format the value and read selectionStart. MDN notes that selectionStart only works on text, search, URL, tel and password inputs, which matters later for the caret.
  • inputMode="numeric" for the digit keyboard on phones. MDN describes it as a numeric keyboard that only needs the digits 0 to 9.
  • autoComplete="cc-number", "cc-exp" and "cc-csc" so the browser can fill saved cards. MDN's autocomplete list says cc-exp is "typically in the form MM/YY or MM/YYYY", so format to MM/YY and autofill lands cleanly.
Credit Card Checkout V1 running live. A live 3D card that fills in as you type and flips for the CVC.

Brand, grouping and length from the first digits

The card network is in the first few digits. The payment card number table on Wikipedia lists the ranges:

  • Visa starts with 4, and is 13, 16 or 19 digits long.
  • Mastercard starts with 51 to 55, or 2221 to 2720. The 2 series went live in October 2016, and a lot of copied regexes still miss it.
  • American Express starts with 34 or 37 and is 15 digits.
  • Discover starts with 6011, 644 to 649 or 65, and is 16 to 19 digits.

Anything else falls back to groups of four and a length between 12 and 19. Here are all the pure helpers in one file. There's nothing React in it, so it's easy to unit test.

card.ts:

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

export const onlyDigits = (value: string) => value.replace(/\D/g, "");

export function detectBrand(digits: string): Brand {
  if (digits.startsWith("4")) return "visa";
  if (/^3[47]/.test(digits)) return "amex";
  if (/^5[1-5]/.test(digits)) return "mastercard";
  const first4 = Number(digits.slice(0, 4));
  if (digits.length >= 4 && first4 >= 2221 && first4 <= 2720) return "mastercard";
  if (/^(6011|64[4-9]|65)/.test(digits)) return "discover";
  return "unknown";
}

const GROUPS: Record<Brand, number[]> = {
  amex: [4, 6, 5],
  visa: [4, 4, 4, 4, 3],
  mastercard: [4, 4, 4, 4],
  discover: [4, 4, 4, 4, 3],
  unknown: [4, 4, 4, 4, 3],
};

const LENGTHS: Record<Brand, number[]> = {
  amex: [15],
  visa: [13, 16, 19],
  mastercard: [16],
  discover: [16, 17, 18, 19],
  unknown: [12, 13, 14, 15, 16, 17, 18, 19],
};

export const maxDigits = (brand: Brand) => Math.max(...LENGTHS[brand]);
export const hasValidLength = (digits: string, brand: Brand) =>
  LENGTHS[brand].includes(digits.length);

export function formatNumber(digits: string, brand: Brand): string {
  const parts: string[] = [];
  let i = 0;
  for (const size of GROUPS[brand]) {
    if (i >= digits.length) break;
    parts.push(digits.slice(i, i + size));
    i += size;
  }
  return parts.join(" ");
}

export function passesLuhn(digits: string): boolean {
  let sum = 0;
  for (let i = 0; i < digits.length; i++) {
    let d = Number(digits[digits.length - 1 - i]);
    if (i % 2 === 1) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }
  return digits.length > 0 && sum % 10 === 0;
}

export function caretAfterDigits(formatted: string, digitCount: number): number {
  if (digitCount <= 0) return 0;
  let seen = 0;
  for (let i = 0; i < formatted.length; i++) {
    if (formatted[i] !== " " && ++seen === digitCount) return i + 1;
  }
  return formatted.length;
}

export function formatExpiry(raw: string): string {
  let d = onlyDigits(raw).slice(0, 4);
  if (d.length === 1 && d > "1") d = "0" + d;
  return d.length > 2 ? `${d.slice(0, 2)}/${d.slice(2)}` : d;
}

export function expiryProblem(value: string, now = new Date()): string | null {
  const match = /^(\d{2})\/(\d{2})$/.exec(value);
  if (!match) return "Use MM/YY.";
  const month = Number(match[1]);
  const year = 2000 + Number(match[2]);
  if (month < 1 || month > 12) return "That month doesn't exist.";
  if (new Date(year, month, 1) <= now) return "This card has expired.";
  return null;
}

A few notes on it:

  • passesLuhn walks from the right and doubles every second digit, subtracting 9 when the double goes past 9. That's the Luhn algorithm. It catches a mistyped digit and most swapped neighbours. It does not tell you the card exists, so the real answer still comes from the payment provider.
  • formatExpiry turns a typed 4 into 04, because no month starts with 4. A typed 1 waits, since it could be 1, 10, 11 or 12.
  • expiryProblem treats a card as valid until the end of its month, so 10/26 still passes on October 21, 2026.

I ran these against Stripe's test cards: 4242 4242 4242 4242, 5555 5555 5555 4444, 2223 0031 2200 3222, 3782 822463 10005 and 6011 1111 1111 1117 all come out with the right brand, grouping, length and a passing Luhn. Changing the last digit of the Visa number makes Luhn fail, as it should.

Keep the caret where the user is typing

This is the bug you'll see first in a card input written by Cursor, v0 or Lovable. The handler reformats the value, React writes it back, and the caret jumps to the end. Type a digit in the middle of the number and the next digit goes in the wrong place.

The fix is to count digits, not characters. Before you format, count how many digits sit to the left of the caret. After you format, put the caret right after that many digits. Spaces can come and go around it and the user never notices. caretAfterDigits does the counting, and the component applies it after React renders with setSelectionRange in a layout effect.

There's one edge case. If the user types a letter, the formatted value doesn't change, so React skips the render and the layout effect never runs. The handler catches that case and writes the value and caret back itself.

CardFields.tsx:

tsx
"use client";

import { useId, useLayoutEffect, useRef, useState, type ChangeEvent } from "react";
import {
  caretAfterDigits,
  detectBrand,
  expiryProblem,
  formatExpiry,
  formatNumber,
  hasValidLength,
  maxDigits,
  onlyDigits,
  passesLuhn,
  type Brand,
} from "./card";

const BRAND_NAME: Record<Brand, string> = {
  visa: "Visa",
  mastercard: "Mastercard",
  amex: "American Express",
  discover: "Discover",
  unknown: "",
};

const field =
  "mt-1 w-full rounded-lg border border-zinc-300 px-3 py-2 font-mono tabular-nums " +
  "focus:outline-none focus:ring-2 focus:ring-zinc-900 aria-invalid:border-red-500";

export function CardFields() {
  const id = useId();
  const numberRef = useRef<HTMLInputElement>(null);
  const pendingCaret = useRef<number | null>(null);
  const [number, setNumber] = useState("");
  const [expiry, setExpiry] = useState("");
  const [cvc, setCvc] = useState("");
  const [touched, setTouched] = useState({ number: false, expiry: false });

  const digits = onlyDigits(number);
  const brand = detectBrand(digits);
  const cvcLength = brand === "amex" ? 4 : 3;
  const numberError =
    touched.number && !(hasValidLength(digits, brand) && passesLuhn(digits))
      ? "Check the card number."
      : null;
  const expiryError = touched.expiry ? expiryProblem(expiry) : null;

  useLayoutEffect(() => {
    const el = numberRef.current;
    if (el && pendingCaret.current !== null && el === document.activeElement) {
      el.setSelectionRange(pendingCaret.current, pendingCaret.current);
    }
    pendingCaret.current = null;
  });

  function onNumberChange(e: ChangeEvent<HTMLInputElement>) {
    const el = e.currentTarget;
    const digitsBeforeCaret = onlyDigits(el.value.slice(0, el.selectionStart ?? el.value.length)).length;
    const nextBrand = detectBrand(onlyDigits(el.value));
    const nextDigits = onlyDigits(el.value).slice(0, maxDigits(nextBrand));
    const next = formatNumber(nextDigits, nextBrand);
    const caret = caretAfterDigits(next, Math.min(digitsBeforeCaret, nextDigits.length));
    if (next === number) {
      el.value = next;
      el.setSelectionRange(caret, caret);
      return;
    }
    pendingCaret.current = caret;
    setNumber(next);
  }

  return (
    <form className="mt-6 grid grid-cols-2 gap-4" onSubmit={(e) => e.preventDefault()}>
      <div className="col-span-2">
        <label htmlFor={`${id}-number`} className="text-sm font-medium">
          Card number
        </label>
        <div className="relative">
          <input
            ref={numberRef}
            id={`${id}-number`}
            type="text"
            inputMode="numeric"
            autoComplete="cc-number"
            placeholder="1234 1234 1234 1234"
            value={number}
            onChange={onNumberChange}
            onBlur={() => setTouched((t) => ({ ...t, number: true }))}
            aria-invalid={numberError ? true : undefined}
            aria-describedby={numberError ? `${id}-number-error` : undefined}
            className={`${field} pr-36`}
          />
          <span aria-live="polite" className="absolute inset-y-0 right-3 flex items-center text-xs text-zinc-500">
            {BRAND_NAME[brand]}
          </span>
        </div>
        {numberError && (
          <p id={`${id}-number-error`} className="mt-1 text-sm text-red-600">
            {numberError}
          </p>
        )}
      </div>

      <div>
        <label htmlFor={`${id}-exp`} className="text-sm font-medium">
          Expiry
        </label>
        <input
          id={`${id}-exp`}
          type="text"
          inputMode="numeric"
          autoComplete="cc-exp"
          placeholder="MM/YY"
          maxLength={5}
          value={expiry}
          onChange={(e) => setExpiry(formatExpiry(e.currentTarget.value))}
          onBlur={() => setTouched((t) => ({ ...t, expiry: true }))}
          aria-invalid={expiryError ? true : undefined}
          aria-describedby={expiryError ? `${id}-exp-error` : undefined}
          className={field}
        />
        {expiryError && (
          <p id={`${id}-exp-error`} className="mt-1 text-sm text-red-600">
            {expiryError}
          </p>
        )}
      </div>

      <div>
        <label htmlFor={`${id}-cvc`} className="text-sm font-medium">
          {brand === "amex" ? "CID (4 digits)" : "CVC"}
        </label>
        <input
          id={`${id}-cvc`}
          type="text"
          inputMode="numeric"
          autoComplete="cc-csc"
          placeholder={"0".repeat(cvcLength)}
          value={cvc.slice(0, cvcLength)}
          onChange={(e) => setCvc(onlyDigits(e.currentTarget.value).slice(0, 4))}
          className={field}
        />
      </div>
    </form>
  );
}

What the component does, beyond the caret:

  • It detects the brand from the digits typed so far and shows its name in a polite live region, so a screen reader hears "Visa" once instead of every keystroke being announced.
  • The CVC field becomes four digits for American Express. Stripe's testing page says the same: any three digit CVC, or four for American Express.
  • Errors wait for blur. Flagging a card number as invalid while someone is on digit six is noise. When an error does show, aria-invalid and aria-describedby tie it to the field.
  • The form has no action and calls preventDefault. Nothing here posts card data anywhere.

Using it in a Next.js page

The page can stay a Server Component. Only CardFields needs "use client", because it holds state and touches the DOM.

app/checkout/page.tsx:

tsx
import type { Metadata } from "next";
import { CardFields } from "./CardFields";

export const metadata: Metadata = { title: "Card form demo" };

export default function Page() {
  return (
    <main className="mx-auto max-w-md p-6">
      <h1 className="text-xl font-semibold">Card details</h1>
      <p className="mt-1 text-sm text-zinc-500">
        Demo only. Try a Stripe test number like 4242 4242 4242 4242.
      </p>
      <CardFields />
    </main>
  );
}

What to check in AI generated card inputs

When an AI editor writes a card form for you, these are the five things I'd check before shipping it:

  1. Is it type="number"? Switch to type="text" with inputMode="numeric".
  2. Does the caret jump to the end when you edit the middle of the number?
  3. Does Mastercard detection include the 2221 to 2720 range?
  4. Is American Express grouped 4 6 5 with a four digit CVC?
  5. Does it show "invalid card" while the user is still typing?

A prompt that heads most of these off: "Write a React card number input with type text and inputMode numeric. Keep the caret position by counting digits before the caret, not characters. Detect Visa, Mastercard including 2221 to 2720, American Express and Discover. Group American Express as 4 6 5. Validate with Luhn on blur, not on change."

Making it look like a card

Once the fields are right, the visual part is a card that mirrors them: the number on the front, the brand logo, and a flip when the CVC field gets focus. The 3D flip card post builds that flip in Tailwind with no library. For the tilt and the moving light, 3D Tilt Card With Glare in React and Tailwind writes the pointer angles to CSS variables inside requestAnimationFrame instead of React state, and keeps the card flat on touch screens and under reduced motion. If you'd rather start from a finished design, the Credit Card Checkout piece is $3 and has the tilt, the glare, the flip and a Pay and success state, plus a renderCardFields slot for Stripe Elements. There's also an overview at animated credit card form for React, and the free pieces if you want to see how the code is written first. For the same kind of input with one digit per box, see the OTP input post.

FAQ

How do I format a credit card number with spaces in React?

Strip everything that isn't a digit, then split the digits into groups based on the brand: 4 4 4 4 for Visa and Mastercard, 4 6 5 for American Express. Join the groups with spaces and store that as the input's value. To stop the caret jumping, count the digits left of the caret before formatting and place the caret after the same number of digits afterwards.

How do I detect the card brand from the number?

Look at the first digits. 4 is Visa. 51 to 55 and 2221 to 2720 are Mastercard. 34 and 37 are American Express. 6011, 644 to 649 and 65 are Discover. You can tell after one to four digits, so the brand can show up while the user is still typing.

Does the Luhn check mean the card is valid?

No. Luhn is a checksum that catches most typing mistakes, like one wrong digit or two swapped neighbours. A number can pass Luhn and still belong to no real card, or to an expired or blocked one. Use it to catch typos early, and let the payment provider decide whether the card is good.

Should I build my own card inputs for real payments?

For real payments, it's better not to. If raw card numbers pass through your own inputs, your site is in scope for more PCI work. Use Stripe Elements or a similar hosted field and keep your own design around it. Custom inputs like these are fine for demos, tests and design work.