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.
<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.
| CardBrand | Mark | Starts with | Groups |
|---|---|---|---|
visa | 4 | 4-4-4-4 | |
mastercard | 51-55, 22-27 | 4-4-4-4 | |
amex | 34, 37 | 4-6-5 | |
discover | 6011, 65 | 4-4-4-4 | |
unknown | anything else | 4-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.
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
| Prop | Type | Default | Notes |
|---|---|---|---|
brand | ReactNode | the detected mark | Overrides the mark at the start of the field. |
onBrandChange | (brand: CardBrand) => void | — | Fires when the detected brand changes. |
required | boolean | false | Renders the asterisk and sets aria-required. |
optional | boolean | false | Renders the muted "(Optional)" note. |
info | string | — | Info marker beside the label, with this as its tooltip. |
Shared
| Prop | Type | Default | Notes |
|---|---|---|---|
value | string | — | Controlled value. |
defaultValue | string | — | Initial value when uncontrolled. |
onChange | (value: string) => void | — | Receives the value, never the event. |
label | ReactNode | — | Rendered as a real <label> wired by htmlFor. |
hint | ReactNode | — | Helper text under the field. |
error | ReactNode | — | Error text. Implies invalid unless invalid says otherwise. |
invalid | boolean | — | Forces the error styling on or off. |
errorKey | string | number | — | Change it to replay the error animation. |
disabled | boolean | false | Greys the field out. |
readOnly | boolean | false | Reads as filled, stays focusable. |
size | 'sm' | 'md' | 'lg' | 'md' | Size scale, where the component defines one. |
classNames | Record<Part, string> | — | Per-part class names. |