Credit Card Form
Card entry with brand-aware grouping and maxLength, a Luhn check, MM/YY with a real expiry rule, blur-time errors and auto-advance — UI only, real payments belong in Stripe Elements.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/credit-card-form.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "CreditCardForm" component (React only,
plus lucide-react for the fallback card glyph).
Contract
- forwardRef<HTMLFormElement>; props extend FormHTMLAttributes minus
onSubmit / defaultValue / children.
- Value shape, digits only — the fields own their formatting, so no separator
ever lives in the value: { number, expiry /* MMYY */, cvc, name, postalCode }.
Controlled: value + onValueChange(next, brand). Uncontrolled: defaultValue
(partial; spaces and slashes are normalized away for you).
- onSubmit(payload, event) fires only when every visible field validates; the
native submit is always prevented. Payload: { number, last4, brand,
expiryMonth 1-12, expiryYear 4 digits, cvc, name, postalCode }.
- Options: showName / showPostalCode (default true), submitLabel, disabled,
now?: Date — the reference "today" for the expiry rule, which makes it
testable and keeps the clock out of render.
- Also export CARD_BRANDS, detectCardBrand(digits, brands?) and
isLuhnValid(digits) so a parent can run the same rules.
Behavior
- One brand table drives everything. Each row: id, label, wordmark, IIN
pattern, accepted PAN lengths, digit groups, accepted CVC lengths, CVC
label. Visa /^4/ 13|16|19, 4-4-4-4, CVV 3. Mastercard 51-55 + 2221-2720,
16, 4-4-4-4, CVC 3. Amex /^3[47]/, 15, 4-6-5, CID 4. Diners
300-305|3095|36|38|39, 14|16|19, 4-6-4. JCB 3528-3589, 16-19. Discover
6011|644-649|65, 16|19 — bare 62 is UnionPay, so Discover must not claim
the whole 62 block. UnionPay /^62/, 16-19. An unknown IIN falls back to
12-19 digits, 4-4-4-4-3, CVC 3 or 4: never reject a plausible card.
- Every edit — typed, pasted, autofilled or handed in as value/defaultValue —
funnels through one normalize(): strip non-digits, detect the brand, clamp
the PAN to that brand's longest length and the CVC to its longest code. That
is what makes pasting "4242 4242 4242 4242" identical to typing it, and what
re-clamps a 4-digit code down to 3 when the number stops being an Amex.
- Caret: count the digits before the caret, re-format, then put the caret back
after that many digits — digit-space is the only coordinate that survives
regrouping. Write the formatted string into the DOM node before setState, so
React's controlled update finds the value already correct and never
reassigns it (that reassignment is exactly what drops the caret at the end).
Keep a layout-effect backstop for renders where a parent normalizes anyway.
- Expiry is MMYY, with the slash drawn the moment the month is complete. A
lone 2-9 pads to 0X and the caret steps over the inserted zero; the
keystroke that would complete a 00 or 13-19 month is refused in place —
refuse it at the keystroke instead of accepting it and complaining later.
Refusal is an insertion rule only. Formatting re-reads the whole digit
stream on every edit, so a formatter that drops a digit fires on digits
nobody typed: one Backspace inside "12/30" hands it "130", and refusing the
"13" would swallow the year too. Deleting must only ever remove the digit
the user aimed at; an impossible month left behind by a paste or a deletion
is reported like any other bad value. The two-digit year expands inside the
reference century. A card is good through the end of its month, so only a
strictly earlier month counts as expired.
- Validation runs on blur and on submit, never on a keystroke; typing clears
the error that is showing. Until the first submit attempt, blur only reports
about content that is actually there — tabbing through an untouched field
must not accuse it of being empty; once the form has said "required" out
loud, blur keeps saying it. Submit validates every visible field, then
focuses the first invalid one. All copy lives in one MESSAGES table.
- Auto-advance: the number hops to the expiry at the brand's longest length,
or at a shorter accepted length that also passes Luhn — never for an unknown
IIN, which accepts 12-19 and would jump away mid-number. The expiry hops to
the code at 4 digits. Backspace at the head of a field returns to the
previous field: the focus chain has to run both ways or auto-advance becomes
a trap. Backspace and Delete alike, aimed at a separator, reach over it and
eat the digit on the far side — removing a separator on its own is a no-op,
because the next render draws it again, so a key that only does that is
stuck on it forever.
- Auto-advance focuses the next field synchronously, so blur fires before
React commits. Validation must therefore read the value from a ref written
at edit time, not from the render closure, or it validates the keystroke
before last.
Rendering & styling
- Semantic tokens only: border-input + bg-transparent fields, text-foreground
labels, text-muted-foreground hints, bg-primary + text-primary-foreground
submit, border-destructive + ring-destructive/20 + text-destructive errors,
ring-ring on focus-visible. No hex/rgb/oklch anywhere; chart tokens never
touch text.
- Brand mark: a short text wordmark in a bordered bg-muted pill — network
logos are licensed assets, and text themes for free. aria-hidden,
pointer-events-none, absolutely positioned inside the number field. The
field's right padding is the mark's measured width plus its inset and a
gap (layout effect + ResizeObserver, so it survives a brand change, a late
webfont and a swapped-in logo). A fixed reservation is dead space under a
short wordmark, and in a 390px column that dead space scrolls the last
digits out of a field that is standing still. Unknown IIN shows a lucide
CreditCard glyph instead.
- Container queries, not viewport breakpoints, so the form survives inside a
390px card or a narrow drawer: @container on the form, expiry + code split
at @[18rem], name + postal at @[26rem].
- Every field reserves a fixed-height message row (min-h-4, leading-4) whether
or not it has a message. Without it, the error that appears on blur pushes
the submit button down between mousedown and mouseup and the first click on
it is silently swallowed — the worst possible moment to drop a click.
- Accessibility: a real label with htmlFor per field; the error node carries
role="alert" and is what aria-describedby points at while it shows
(otherwise the code-length hint); aria-invalid on the reported field; one
polite sr-only live region announces the detected brand and its code length,
because the wordmark is decorative. The only transition is a color
transition, disabled under motion-reduce. No timers, so nothing to clean up.
- autoComplete cc-number / cc-exp / cc-csc / cc-name / "billing postal-code",
inputMode="numeric" on the three digit fields, and deliberately NO name
attribute on any input so a stray native submit can never post a card
number anywhere.
Security — repeat this in your own docs
- This component is UI. It formats, brands and validates; it does not
tokenize, encrypt or transmit anything. A production payment flow must never
let a raw PAN reach your servers, logs or analytics: mount Stripe Elements,
Adyen Web Components or Braintree Hosted Fields and let the processor's
iframe own the card data. Use this for design work, for flows that tokenize
in the browser before submit, or as the visual shell around hosted inputs.
Customization levers
- The brand table is the main lever: delete a row to refuse a network (
detectCardBrand takes the list), add a row for a private-label card
(pattern + lengths + groups + cvcLengths + cvcLabel), or swap the wordmark
pill for an img logo if you have the license to ship one.
- Copy: MESSAGES plus the five field labels are the entire i18n surface; the
code label already follows the brand (CVV / CVC / CID / CVN).
- Fields: showName / showPostalCode; drop the postal row entirely if your
processor does no AVS. Adding a field is one entry in the visible-field
order array plus one branch in validateField.
- Density and width: h-9 / px-3 / gap-4, and the two container-query
breakpoints. The component sets no max width on purpose — wrap it in
max-w-md for a checkout column, leave it wide in a settings panel.
- Strictness: swap the deliberately loose postal regex for a country-specific
one driven by a country select, or reject brand.id === "unknown" if you only
accept the networks you listed.
- Timing: validate as you type by calling validateField in the change handler
instead of clearing the error there; or drop the auto-advance rule entirely
if your users find moved focus hostile on mobile.
- Submit: it is a plain button with type="submit" — replace it with your own
Button, or render it outside the form and point it back with form={id}.Concepts
- IIN-driven layout — the first digits pick a table row, and that row decides the digit grouping, the field's
maxLength, how many digits the security code takes and what it is called. Nothing about the shape of the form is hardcoded; changing a card changes all four at once. - Luhn is a typo net, not a fraud check — mod-10 catches a mistyped or transposed digit before you burn an authorization attempt. It says nothing about whether the card exists or has funds; only the processor can answer that.
- Digits-before-caret — the caret is stored as "how many digits are behind it", never as a string index. That is the one coordinate that survives regrouping, so fixing a typo in the middle of the number does not fling the cursor to the end.
- Refuse in place, report on blur, require on submit — an impossible month (
13) is rejected at the keystroke that types it, because there is no useful error to write about it. Refusal stops there: formatting runs again on every deletion, and a formatter that drops digits would eat the year while you fix the month, so a13that arrives by paste or by deletion is reported like any other bad value. A wrong check digit or an expired card is reported when the field loses focus; "this is required" waits for submit, so tabbing through the form never accuses an untouched field. - Two-way focus chain — auto-advance is only acceptable if
Backspaceat the head of a field walks back. One-way advance is a focus trap the moment someone mistypes. - The clock is injected — the expiry rule takes a
nowdate so "expired" is a testable pure function and render never reads the real clock. The docs above pin it to 2026-03-15, which is why02/26fails and03/26passes. - UI, not a payment integration — the form hands you the raw PAN and nothing else happens. Real money must go through a PCI-compliant hosted field set (Stripe Elements, Adyen, Braintree) whose iframe holds the card data on the processor's domain; the inputs here carry no
nameattribute so a stray native submit cannot post a card number by accident.
Form Field
A label + control wrapper that wires id, aria-describedby, invalid, required, disabled and a character counter onto whatever control you put inside it.
Availability Picker
A week × time-slot grid you brush with the pointer to declare when you are free — paint/erase strokes, keyboard painting, blocked slots and an optional group heatmap.