Skip to contentVibraUI
Inputs & filters

Payment method form

Card number, expiry, code and name — brand from the issuer digits, spaces as you type, Luhn on blur.

Three rules do the work. The number is written the way it is printed — fours, or Amex's four-six-five — so it can be read back against the card in hand, and a paste of a longer string is cut at the brand's own length rather than overrunning the field. Each field is checked when it is left, not on every keystroke, because telling someone their card number is wrong while they are still typing it is noise; the submit checks all four at once, so a reader fixes one card rather than one field per attempt. And every complaint is rendered beside its field *and* named as that field's accessible description, so it is heard as well as seen — including the processor's own refusal when the Result names a field. Nothing here talks to a processor: these checks catch what a reader can fix before the round trip, and onSubmit is where the real answer comes from. The brand comes from the issuer digits as soon as they decide it — one digit for Visa, two for Amex, four for Mastercard's 2-series, six for one of Discover's ranges — and drives the number's grouping, its length, and whether the security code is three digits or four. The card face mirrors what is printed on the front and is aria-hidden, because every value on it is already in a labelled field; it shows the brand's name rather than its mark, since a network's logo is that network's artwork and an approximation of one is worse than the word. validate.ts holds every rule with no React in it, so a checkout can reach for detectBrand or luhn without mounting a form. The root is a <form>: with onSubmit it grows a Save button and answers Enter; without one it is a field group you drive yourself. onSubmit's result type is the sample-data Result narrowed to what the form reads, so a server action returning Result<T> is assignable as it stands and nothing drags the store in behind it.

Install

npx shadcn@latest add @vibra/payment-method-form

Needs the @vibra registry in your components.json — set it up once.

Examples

Props

PropTypeDefaultDescription
value{ number: string; expiry: string; cvc: string; name: string }—Present makes the form controlled; leave it out and the form keeps its own.
onValueChange(value: PaymentMethodValue) => void—Every keystroke, already formatted — grouped digits, MM/YY, code cut to the brand's length.
onSubmit(value: PaymentMethodValue) => void | Promise<PaymentMethodResult>—Called only once all four fields pass. Answer { ok: false, error: { message, field? } } and the message lands beside that field, or above the form when it names none.
brands("visa" | "mastercard" | "amex" | "discover")[]—Which networks are taken; a number from any other reads as a card you do not take.
detectBrand(input: string) => PaymentBrand | null—The brand from the issuer digits, as soon as enough of them are in to be sure.
luhn(input: string) => boolean—The check digit every card number ends on.
formatCardNumber / formatExpiry / maskCardNumber(input: string, brand?: PaymentBrand | null) => string—The number in the brand's grouping, MM/YY, and the card face's bullets for what is not typed yet.
validateAll(value, options?: { brands?: PaymentBrand[]; now?: Date }) => Partial<Record<keyof PaymentMethodValue, string>>—Every field's complaint at once, keyed by field. validateNumber, validateExpiry, validateCvc and validateName are the four it calls.
PaymentCardVisual{ number: string; name: string; expiry: string; brand: PaymentBrand | null }—The card face on its own, for a review step that shows the card without the fields.

Dependencies

Source

components/ui/payment-method-form/index.tsx
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"
import { AsyncButton } from "@/components/ui/async-button"
import { Field, FieldError, FieldGroup, FieldLabel } from "@/components/ui/field"
import { Input } from "@/components/ui/input"

import { PaymentCardVisual } from "./card-visual"
import {
  BRAND_LABELS,
  cvcLength,
  detectBrand,
  digitsOf,
  formatCardNumber,
  formatExpiry,
  validateAll,
  validateCvc,
  validateExpiry,
  validateName,
  validateNumber,
  type PaymentBrand,
  type PaymentMethodValue,
} from "./validate"

// One import builds the whole form: the card face, and every rule behind it for
// a checkout that wants to check a number before it opens this form at all.
export { PaymentCardVisual }
export {
  BRAND_LABELS,
  cvcLength,
  detectBrand,
  formatCardNumber,
  formatExpiry,
  luhn,
  maskCardNumber,
  validateAll,
  validateCvc,
  validateExpiry,
  validateName,
  validateNumber,
  type PaymentBrand,
  type PaymentMethodValue,
} from "./validate"

/**
 * What `onSubmit` may answer with — the sample-data `Result` narrowed to what
 * this form reads, so a server action returning `Result<T>` is assignable as it
 * stands and nothing here drags the store in behind it. `error.field` names one
 * of the four fields, and the message lands beside it.
 */
export type PaymentMethodResult =
  | { ok: true }
  | { ok: false; error: { message: string; field?: string } }

export type PaymentMethodFormProps = {
  className?: string
  /** Present makes the form controlled; every keystroke comes back through onValueChange. */
  value?: PaymentMethodValue
  onValueChange?: (value: PaymentMethodValue) => void
  /** Given, the form grows a submit button and checks every field before calling it. */
  onSubmit?: (value: PaymentMethodValue) => void | Promise<PaymentMethodResult>
  /** Which networks are taken; anything else is refused with the number. */
  brands?: PaymentBrand[]
}

const EMPTY: PaymentMethodValue = { number: "", expiry: "", cvc: "", name: "" }

type Errors = Partial<Record<keyof PaymentMethodValue, string>>

/**
 * Card number, expiry, security code and name, checked while they are typed.
 *
 * Three rules do the work. The number is written the way it is printed — fours,
 * or Amex's four-six-five — so it can be read back against the card in hand.
 * Each field is checked when it is left rather than on every keystroke, because
 * telling someone their card number is wrong while they are still typing it is
 * noise. And every complaint is rendered beside the field that caused it and
 * named as that field's description, so it is heard as well as seen — including
 * the processor's own refusal, when it says which field it is about.
 *
 * Nothing here talks to a processor: it catches what a reader can fix before
 * the round trip, and `onSubmit` is where the real answer comes from.
 */
function PaymentMethodForm({
  className,
  value,
  onValueChange,
  onSubmit,
  brands,
}: PaymentMethodFormProps) {
  const id = React.useId()
  const field = {
    number: `${id}-number`,
    expiry: `${id}-expiry`,
    cvc: `${id}-cvc`,
    name: `${id}-name`,
  }

  const [uncontrolled, setUncontrolled] = React.useState<PaymentMethodValue>(EMPTY)
  const [errors, setErrors] = React.useState<Errors>({})
  const [formError, setFormError] = React.useState<string | null>(null)
  const [pending, setPending] = React.useState(false)

  const current = value ?? uncontrolled
  const brand = detectBrand(current.number)

  function update(patch: Partial<PaymentMethodValue>) {
    const next = { ...current, ...patch }
    setUncontrolled(next)
    onValueChange?.(next)
  }

  /** Records or clears one field's complaint, leaving the others where they are. */
  function report(name: keyof PaymentMethodValue, message: string | null) {
    setErrors((previous) => {
      const next = { ...previous }
      if (message) next[name] = message
      else delete next[name]
      return next
    })
  }

  async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
    event.preventDefault()
    setFormError(null)

    // Everything at once, so a reader fixes one card rather than one field per
    // attempt.
    const found = validateAll(current, { brands })
    setErrors(found)
    if (Object.keys(found).length > 0) return

    setPending(true)
    try {
      const result = await onSubmit?.(current)
      if (!result || result.ok) return
      // A refusal that names a field belongs beside it; one that does not is
      // about the whole attempt.
      if (result.error.field && result.error.field in current)
        report(result.error.field as keyof PaymentMethodValue, result.error.message)
      else setFormError(result.error.message)
    } catch (caught) {
      // A lost connection rejects rather than answering { ok: false }; it
      // cannot name a field, so it reads the way an unnamed refusal does.
      setFormError(caught instanceof Error ? caught.message : "Something went wrong. Try again.")
    } finally {
      setPending(false)
    }
  }

  /** The description a field gets while it has a complaint, and none otherwise. */
  const describedBy = (name: keyof PaymentMethodValue) =>
    errors[name] ? `${field[name]}-error` : undefined

  return (
    <form
      data-slot="payment-method-form"
      // The browser's own bubbles would say something different, in a different
      // place, from the messages below.
      noValidate
      onSubmit={handleSubmit}
      className={cn("flex w-full flex-col gap-5", className)}
    >
      <PaymentCardVisual
        number={current.number}
        name={current.name}
        expiry={current.expiry}
        brand={brand}
      />

      <FieldGroup>
        <Field data-invalid={errors.number ? true : undefined}>
          <FieldLabel htmlFor={field.number}>Card number</FieldLabel>
          <div className="relative">
            <Input
              id={field.number}
              value={current.number}
              onChange={(event) => update({ number: formatCardNumber(event.target.value) })}
              onBlur={() => report("number", validateNumber(current.number, brands))}
              aria-invalid={errors.number ? true : undefined}
              aria-describedby={describedBy("number")}
              autoComplete="cc-number"
              inputMode="numeric"
              placeholder="1234 1234 1234 1234"
              className="pe-24 tabular-nums"
            />
            {brand ? (
              <span
                data-slot="payment-method-brand"
                className="pointer-events-none absolute inset-y-0 end-2.5 flex items-center text-xs font-medium text-muted-foreground"
              >
                {BRAND_LABELS[brand]}
              </span>
            ) : null}
          </div>
          <FieldError id={`${field.number}-error`}>{errors.number}</FieldError>
        </Field>

        <div className="grid grid-cols-2 gap-4">
          <Field data-invalid={errors.expiry ? true : undefined}>
            <FieldLabel htmlFor={field.expiry}>Expiry</FieldLabel>
            <Input
              id={field.expiry}
              value={current.expiry}
              onChange={(event) => update({ expiry: formatExpiry(event.target.value) })}
              onBlur={() => report("expiry", validateExpiry(current.expiry))}
              aria-invalid={errors.expiry ? true : undefined}
              aria-describedby={describedBy("expiry")}
              autoComplete="cc-exp"
              inputMode="numeric"
              placeholder="MM/YY"
              className="tabular-nums"
            />
            <FieldError id={`${field.expiry}-error`}>{errors.expiry}</FieldError>
          </Field>

          <Field data-invalid={errors.cvc ? true : undefined}>
            <FieldLabel htmlFor={field.cvc}>Security code</FieldLabel>
            <Input
              id={field.cvc}
              value={current.cvc}
              onChange={(event) =>
                update({ cvc: digitsOf(event.target.value).slice(0, cvcLength(brand)) })
              }
              onBlur={() => report("cvc", validateCvc(current.cvc, brand))}
              aria-invalid={errors.cvc ? true : undefined}
              aria-describedby={describedBy("cvc")}
              autoComplete="cc-csc"
              inputMode="numeric"
              placeholder={"•".repeat(cvcLength(brand))}
              className="tabular-nums"
            />
            <FieldError id={`${field.cvc}-error`}>{errors.cvc}</FieldError>
          </Field>
        </div>

        <Field data-invalid={errors.name ? true : undefined}>
          <FieldLabel htmlFor={field.name}>Name on card</FieldLabel>
          <Input
            id={field.name}
            value={current.name}
            onChange={(event) => update({ name: event.target.value })}
            onBlur={() => report("name", validateName(current.name))}
            aria-invalid={errors.name ? true : undefined}
            aria-describedby={describedBy("name")}
            autoComplete="cc-name"
            placeholder="As printed on the card"
          />
          <FieldError id={`${field.name}-error`}>{errors.name}</FieldError>
        </Field>
      </FieldGroup>

      {formError ? (
        <div role="alert" data-slot="payment-method-error" className="text-sm text-danger">
          {formError}
        </div>
      ) : null}

      {onSubmit ? (
        <AsyncButton type="submit" loading={pending} className="w-full">
          Save card
        </AsyncButton>
      ) : null}
    </form>
  )
}

export { PaymentMethodForm }
components/ui/payment-method-form/card-visual.tsx
import * as React from "react"

import { cn } from "@/lib/utils"
import { BRAND_LABELS, maskCardNumber, type PaymentBrand } from "./validate"

export type PaymentCardVisualProps = React.ComponentProps<"div"> & {
  /** The number as it stands in the field; the rest of the digits are bullets. */
  number: string
  name: string
  expiry: string
  brand: PaymentBrand | null
}

/**
 * The card face, mirroring the fields beside it.
 *
 * Decoration and nothing else: every value on it is already in a labelled field
 * above, so it is `aria-hidden` rather than read out twice. It shows what is
 * printed on the front — the number, the name and the expiry — and not the
 * security code, which is on the back of a real card and would be a lie here.
 *
 * The brand is its name, not its mark. A network's logo is that network's
 * artwork; an approximation of one is worse than the word, and the word reads
 * out.
 */
function PaymentCardVisual({
  className,
  number,
  name,
  expiry,
  brand,
  ...props
}: PaymentCardVisualProps) {
  return (
    <div
      data-slot="payment-method-form-card"
      data-brand={brand ?? "unknown"}
      aria-hidden="true"
      className={cn(
        "flex aspect-[8/5] w-full max-w-80 flex-col justify-between rounded-xl bg-secondary p-4 text-secondary-foreground ring-1 ring-border select-none",
        className
      )}
      {...props}
    >
      <div className="flex items-start justify-between">
        {/* The contact plate, which is the one thing on a card that is a shape
            rather than a word. */}
        <span className="h-6 w-8 rounded-[4px] bg-foreground/15 ring-1 ring-foreground/10" />
        <span className="type-eyebrow text-muted-foreground">
          {brand ? BRAND_LABELS[brand] : "Card"}
        </span>
      </div>

      <div className="font-mono text-sm tracking-wide tabular-nums sm:text-base">
        {maskCardNumber(number, brand)}
      </div>

      <div className="flex items-end justify-between gap-3">
        <span className="min-w-0 truncate text-xs uppercase">{name || "Name on card"}</span>
        <span className="shrink-0 text-xs tabular-nums">{expiry || "MM/YY"}</span>
      </div>
    </div>
  )
}

export { PaymentCardVisual }
components/ui/payment-method-form/validate.ts
/**
 * Card rules, with no React in them: what a number's issuer digits say it is,
 * how it should be written, and what is wrong with it.
 *
 * None of this replaces the processor's own answer — a number can pass every
 * check here and still be declined — but each of these catches a mistake the
 * reader can fix while their hands are still on the keys, which is the only
 * moment fixing it is cheap.
 */

export type PaymentBrand = "visa" | "mastercard" | "amex" | "discover"

/**
 * How each brand is named where a name is shown. Names, never marks: a card
 * logo is the network's artwork, and an approximation of one is worse than the
 * word — the word is unambiguous, translatable, and reads out.
 */
export const BRAND_LABELS: Record<PaymentBrand, string> = {
  visa: "Visa",
  mastercard: "Mastercard",
  amex: "Amex",
  discover: "Discover",
}

/** How many digits each brand's numbers run to, and how they are grouped. */
const BRAND_SHAPE: Record<PaymentBrand, { length: number; groups: number[]; cvc: number }> = {
  visa: { length: 16, groups: [4, 4, 4, 4], cvc: 3 },
  mastercard: { length: 16, groups: [4, 4, 4, 4], cvc: 3 },
  amex: { length: 15, groups: [4, 6, 5], cvc: 4 },
  discover: { length: 16, groups: [4, 4, 4, 4], cvc: 3 },
}

const UNKNOWN_SHAPE = { length: 16, groups: [4, 4, 4, 4], cvc: 3 }

const shapeOf = (brand: PaymentBrand | null) => (brand ? BRAND_SHAPE[brand] : UNKNOWN_SHAPE)

/** Just the digits — the field writes spaces in, and every rule here reads past them. */
export const digitsOf = (input: string): string => input.replace(/\D/g, "")

/**
 * The brand from the issuer identification number, as soon as enough of it has
 * been typed to be sure — one digit for Visa, two for Amex, four for
 * Mastercard's 2-series.
 */
export function detectBrand(input: string): PaymentBrand | null {
  const digits = digitsOf(input)
  if (digits === "") return null

  if (/^4/.test(digits)) return "visa"
  if (/^3[47]/.test(digits)) return "amex"
  if (/^5[1-5]/.test(digits)) return "mastercard"

  // Mastercard's second range is 222100–272099, which only the first four
  // digits can decide; a shorter prefix is not yet an answer.
  const four = Number(digits.slice(0, 4))
  if (digits.length >= 4 && four >= 2221 && four <= 2720) return "mastercard"

  if (/^6011/.test(digits)) return "discover"
  if (/^65/.test(digits)) return "discover"
  if (/^64[4-9]/.test(digits)) return "discover"
  const six = Number(digits.slice(0, 6))
  if (digits.length >= 6 && six >= 622126 && six <= 622925) return "discover"

  return null
}

/** The Luhn check digit: every card number ends on one, and a typo almost never does. */
export function luhn(input: string): boolean {
  const digits = digitsOf(input)
  if (digits === "") return false

  let sum = 0
  let double = false
  for (let i = digits.length - 1; i >= 0; i--) {
    let value = digits.charCodeAt(i) - 48
    if (double) {
      value *= 2
      if (value > 9) value -= 9
    }
    sum += value
    double = !double
  }
  return sum % 10 === 0
}

/**
 * The number written the way it is printed on the card: fours for most brands,
 * four-six-five for Amex, cut at the brand's own length so a paste of a longer
 * string cannot overrun the field.
 */
export function formatCardNumber(input: string, brand?: PaymentBrand | null): string {
  const shape = shapeOf(brand ?? detectBrand(input))
  const digits = digitsOf(input).slice(0, shape.length)

  const parts: string[] = []
  let at = 0
  for (const size of shape.groups) {
    if (at >= digits.length) break
    parts.push(digits.slice(at, at + size))
    at += size
  }
  return parts.join(" ")
}

/**
 * The number as the card face shows it: the digits typed so far, and a bullet
 * for every one still to come, in the brand's own grouping — so the visual has
 * a card's shape from the first keystroke rather than growing one.
 */
export function maskCardNumber(input: string, brand?: PaymentBrand | null): string {
  const shape = shapeOf(brand ?? detectBrand(input))
  const digits = digitsOf(input).slice(0, shape.length)
  const filled = digits.padEnd(shape.length, "•")

  const parts: string[] = []
  let at = 0
  for (const size of shape.groups) {
    parts.push(filled.slice(at, at + size))
    at += size
  }
  return parts.join(" ")
}

/** MM/YY, with the slash appearing once the month has been typed past. */
export function formatExpiry(input: string): string {
  const digits = digitsOf(input).slice(0, 4)
  if (digits.length <= 2) return digits
  return `${digits.slice(0, 2)}/${digits.slice(2)}`
}

/** How many digits the security code has on this brand: four on Amex, three elsewhere. */
export const cvcLength = (brand: PaymentBrand | null): number => shapeOf(brand).cvc

/** What is wrong with the number, or null. `brands` narrows what is accepted. */
export function validateNumber(input: string, brands?: PaymentBrand[]): string | null {
  const digits = digitsOf(input)
  if (digits === "") return "Enter the card number."

  const brand = detectBrand(digits)
  if (!brand || (brands && !brands.includes(brand))) return "We do not take that card."

  const { length } = BRAND_SHAPE[brand]
  // The length is said before the checksum, because a half-typed number fails
  // Luhn too and "check the card number" is the wrong thing to say about one.
  if (digits.length !== length) return `${BRAND_LABELS[brand]} numbers have ${length} digits.`
  if (!luhn(digits)) return "Check the card number — a digit is off."

  return null
}

/** What is wrong with the expiry, or null. A card is good to the last day of its month. */
export function validateExpiry(input: string, now: Date = new Date()): string | null {
  const match = /^(\d{2})\/(\d{2})$/.exec(formatExpiry(input))
  if (!match) return "Use MM / YY."

  const month = Number(match[1])
  if (month < 1 || month > 12) return "Enter a month between 01 and 12."

  // Two digits are this century's: 30 is 2030. A card issued to expire in 1999
  // is not a case this has to read.
  const year = 2000 + Number(match[2])
  // The first instant of the month after the one on the card.
  const expiresAfter = new Date(Date.UTC(year, month, 1))
  return expiresAfter.getTime() > now.getTime() ? null : "This card has expired."
}

/** What is wrong with the security code, or null. */
export function validateCvc(input: string, brand: PaymentBrand | null): string | null {
  const needed = cvcLength(brand)
  if (!new RegExp(`^\\d{${needed}}$`).test(input.trim()))
    return `Enter the ${needed} digits from the card.`
  return null
}

/** What is wrong with the cardholder's name, or null. */
export function validateName(input: string): string | null {
  return input.trim() === "" ? "Enter the name on the card." : null
}

export type PaymentMethodValue = {
  number: string
  expiry: string
  cvc: string
  name: string
}

/** Every field's complaint at once, keyed by field: what a submit checks. */
export function validateAll(
  value: PaymentMethodValue,
  options: { brands?: PaymentBrand[]; now?: Date } = {}
): Partial<Record<keyof PaymentMethodValue, string>> {
  const { brands, now } = options
  const errors: Partial<Record<keyof PaymentMethodValue, string>> = {}

  const number = validateNumber(value.number, brands)
  if (number) errors.number = number

  const expiry = validateExpiry(value.expiry, now)
  if (expiry) errors.expiry = expiry

  // The code's length is the brand's, so it is checked against whatever the
  // number turned out to be rather than against a default.
  const cvc = validateCvc(value.cvc, detectBrand(value.number))
  if (cvc) errors.cvc = cvc

  const name = validateName(value.name)
  if (name) errors.name = name

  return errors
}