IInbox
Composed

Card field

Three inputs behaving as one control — brand detection, live grouping, Luhn, and a caret that stays where you put it.

Live at /lab/payment.

The interesting part of a card field is entirely in what does not happen. The caret does not jump. The brand does not flicker. No error appears while a value can still become valid. Focus moves forward on its own but never backwards against your will.

Installation

npx shadcn@latest add https://ui.whoisroktim.lol/r/card-field.json

The caret

This is the one almost everybody gets wrong. Reformatting on every keystroke replaces the input's value, and the browser then puts the caret at the end — so editing the middle of a number silently teleports you. The field shows a correct value and feels broken.

caretAfterFormat(formatted, rawCaret, rawValue);

Count the digits before the caret, then walk the reformatted string until that many digits have gone past. Clamping the old index does not work, because inserting a digit can also insert a space. Verified in a browser rather than asserted: 4242 4242 4242 with the caret at 6, typing 9, gives 4242 4924 2424 2 with the caret at 7 — immediately after the digit you typed.

Brands are ranges, not prefixes

Mastercard is 51–55 and 2221–2720. Amex is 15 digits grouped 4-6-5 with a four-digit security code. Diners is 14 grouped 4-6-4. Treating every card as 4-4-4-4 is the most visible bug this component can have.

detectBrand truncates each range's bounds to the number of digits typed, so a prefix resolves as early as it honestly can — 2 is already Mastercard, since no other network issues a 2-prefixed card — while 28 correctly falls back to unknown instead of staying latched on.

Errors that wait their turn

A half-typed number is not an invalid number.

StateMessage
4242nothing — unfinished, not wrong
4242 4242 4242 4241"That card number is not valid"
expiry 1nothing
expiry 13"That month does not exist" — it can never become valid

Only one message shows at a time. Three simultaneous errors under a single control read as a broken form rather than as three problems. The message row also reserves its height, so nothing shifts when one appears.

Details worth keeping

The focus ring is on the row, not the focused input — the three inputs are one control to the person filling them in. Advance happens on completeness, not on length, so a 19-digit Visa is not stranded halfway. The expiry accepts the current month: cards work through the last day of the month they expire in.

All of it is tested in tests/dom/card-utils.test.ts, with now injected so the expiry cases are not a time bomb.