HomeDocsComponentsBlocksForm contractValidation
Components / CardInput

CardInput

Number, expiry and CVC as one component with one canonical value. The grouping reflows as the brand is detected — 4-4-4-4 for Visa, 4-6-5 for Amex — and the CVC length follows it. Focus advances to the expiry only when the number is both a valid length for its brand and passes Luhn, because Visa accepts 16, 18 and 19 digits and advancing on the first valid length strands anyone with a 16-digit card.

$ npx shadcn@latest add https://input-cn.vercel.app/r/card-input.json
Build with an agent

Hand this to Claude Code

1,750 chars

Most people reach this component through an agent rather than by reading the page. Pick how you want it wired and take a prompt that already carries the canonical value, the real prop names and the mistakes worth avoiding.

Wiring
Extras
Open in Claude Code

Nothing runs on its own. The link opens the tool with the prompt typed into the box; you read it and press Enter. If the button does nothing, Claude Code is not installed on this machine — use Copy prompt instead.

Live demo

Try 4242… then 3782… — the grouping reflows to 4-6-5.

value = "" · digits only

Constraints

Validation rules, declared as props. Each one produces its own message and its own data-rule value, so a test can assert the rule rather than the sentence.

PROPTYPEDESCRIPTION
brands?readonly CardBrand[]Restrict to the brands your PSP actually accepts.
notExpired?booleanReject a card whose expiry has passed. Requires an expiry value.
requireCvc?booleanRequire the CVC to be filled to the brand's length.

Props

PROPTYPEDESCRIPTION
expiry?stringExpiry as typed, e.g. "04 / 28". Controlled separately from the number.
defaultExpiry?string—
onExpiryChange?(value: string) => void—
cvc?string—
defaultCvc?string—
onCvcChange?(value: string) => void—
label?ReactNodeVisible label. Supply this or `aria-label` — a field with neither has no accessible name.
hint?ReactNodeHelper text under the field. Replaced by the error message while one is showing.
layout?CardLayout`stacked` is three fields; `single` is one row, Stripe-style.
expiryName?stringField names for native submission.
cvcName?string—

Shared props

Implemented identically by all eleven components. These tables are generated from the source interfaces at build time, so a renamed prop changes this page rather than quietly making it wrong.

PROPTYPEDESCRIPTION
value?TCanonical value. Controlled mode.
defaultValue?TCanonical value. Uncontrolled mode. Mutually exclusive with `value`.
onChange?(value: T) => voidEmits the canonical value — never a DOM event.
onBlur?() => voidFires when focus leaves the whole component, not between its parts.
name?stringApplied to the hidden canonical input so FormData carries the real value.
disabled?boolean—
readOnly?boolean—
required?boolean | string—
validate?(value: T) => true | stringCustom synchronous rule. Return true, or the message to display.
messages?MessageMapOverride built-in messages by rule name.
showError?ShowErrorDefault `"touched"`.
onValidityChange?(state: ValidityState) => voidReports validity upward regardless of mode.
mode?FieldModeForce a mode instead of auto-detecting. Escape hatch only.
variant?FieldVariantSurface treatment. Default `"outline"`.
size?FieldSizeDensity. Default `"default"` (32px).
id?string—
className?string—
placeholder?string—
autoFocus?boolean—
aria-label?string—
aria-labelledby?string—
aria-describedby?string—
aria-invalid?boolean | "true" | "false"—

With react-hook-form

import { useForm, Controller } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { cardSchema } from "@inputcn/card/schema"
import { CardInput } from "@/components/ui/card-input"
import { z } from "zod"

// The companion schema validates the CANONICAL value, so the resolver and the
// field can never disagree about what "valid" means.
const schema = z.object({ card: cardSchema() })

export function Example() {
  const { control } = useForm({ resolver: zodResolver(schema) })

  return (
    <Controller
      name="card"
      control={control}
      render={({ field, fieldState }) => (
        <CardInput
          label="Card details"
          {...field}
          aria-invalid={fieldState.invalid}
        />
      )}
    />
  )
}

With a Server Action

import { CardInput } from "@/components/ui/card-input"

export default function Page() {
  return (
    <form action={save}>
      <CardInput name="card" label="Card details" />
      <button>Save</button>
    </form>
  )
}

async function save(data: FormData) {
  "use server"
  // The hidden canonical input carries the real value, not the display text.
  const card = data.get("card") // "4242424242424242"
}

Keyboard

KEYDOES
TabNumber, then expiry, then CVC.
Auto-advanceNumber to expiry on a valid, Luhn-passing number.

Worth knowing

This is not a PCI-compliant capture path on its own. It is an input; the compliance boundary is your payment processor iframe.

Emits string, for example "4242424242424242". Keyboard operation is covered by automated tests; screen readers have not been verified yet.