Card number

A card number that regroups itself as it recognises the card, and a mark that changes with it. It does not tell you whether the number is real.

(Optional)
The mark follows the number. Try 5555 5555 5555 4444.
<CardInput
  size="md"
  label="Card number"
  required
  optional
  info="We never store the number."
  hint="The mark follows the number. Try 3782 822463 10005."
/>

Grouping follows the brand

Amex is 4-6-5 and everything else is four fours. A field that regroups your digits the moment it recognises the card is the clearest signal it could give that it read them. You get the confirmation for free, from the thing you were doing anyway.

CardBrandMarkStarts withGroups
visa44-4-4-4
mastercard51-55, 22-274-4-4-4
amex34, 374-6-5
discover6011, 654-4-4-4
unknownanything else4-4-4-4

The mark is on the left

The design draws a generic card glyph at the start and the network's plate at the end, which says the same thing twice. One mark is enough, and it belongs at the start: that is where your eye already is, because it is where the number begins. So the glyph becomes the card.

The slot it sits in is a fixed 29px whatever is in it, including nothing. That is not a detail: the plain glyph is 20px square and the network plates are 29px wide, so a slot sized to its contents shunted every digit sideways the moment the card was recognised. A field that jolts as it understands you is worse than one that never noticed.

All four marks ship, drawn on one 780×500 plate so they read as a family rather than four different treatments. They are the networks' trademarks, shown to say which card was recognised. A real checkout should use the assets each network distributes under its own brand guidelines, and brand takes any node for that. The detected brand is handed to you either way, so you never have to redo the detection.

checkout.tsx
const [brand, setBrand] = useState<CardBrand>('unknown')

// Your own artwork, or your processor's:
<CardInput
  onBrandChange={setBrand}
  brand={<img src={`/brands/${brand}.svg`} alt="" width={29} />}
/>

No Luhn check

Detection is deliberately shallow: enough to group correctly and show a mark, not a validator. A Luhn check passes for numbers no bank ever issued and fails for nothing your processor will not catch a moment later, so it would be a confident answer to the wrong question. Pass invalid and error when the processor answers the right one.

Props

PropTypeDefaultNotes
brandReactNodethe detected markOverrides the mark at the start of the field.
onBrandChange(brand: CardBrand) => voidFires when the detected brand changes.
requiredbooleanfalseRenders the asterisk and sets aria-required.
optionalbooleanfalseRenders the muted "(Optional)" note.
infostringInfo marker beside the label, with this as its tooltip.

Shared

PropTypeDefaultNotes
valuestringControlled value.
defaultValuestringInitial value when uncontrolled.
onChange(value: string) => voidReceives the value, never the event.
labelReactNodeRendered as a real <label> wired by htmlFor.
hintReactNodeHelper text under the field.
errorReactNodeError text. Implies invalid unless invalid says otherwise.
invalidbooleanForces the error styling on or off.
errorKeystring | numberChange it to replay the error animation.
disabledbooleanfalseGreys the field out.
readOnlybooleanfalseReads as filled, stays focusable.
size'sm' | 'md' | 'lg''md'Size scale, where the component defines one.
classNamesRecord<Part, string>Per-part class names.