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.
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.
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.
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.
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.
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.
When an AI editor writes a card form for you, these are the five things I'd check before shipping it:
Is it type="number"? Switch to type="text" with inputMode="numeric".
Does the caret jump to the end when you edit the middle of the number?
Does Mastercard detection include the 2221 to 2720 range?
Is American Express grouped 4 6 5 with a four digit CVC?
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."
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.
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.
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.
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.
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.