Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CreditCard } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- */
/* Brands                                                                     */
/* -------------------------------------------------------------------------- */

export type CardBrandId =
  | "visa"
  | "mastercard"
  | "amex"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/credit-card-form.json

Prompt

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 a 13 that 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 Backspace at 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 now date 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 why 02/26 fails and 03/26 passes.
  • 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 name attribute so a stray native submit cannot post a card number by accident.

On This Page