{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "credit-card-input",
  "type": "registry:ui",
  "title": "Credit card input",
  "description": "Luhn (mod 10) checksum, the check digit rule every major card number follows.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "https://ui.minidev.pro/r/input.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/credit-card-input.tsx",
      "type": "registry:ui",
      "target": "components/ui/credit-card-input.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport { CircleAlertIcon, CreditCardIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\nimport { inputVariants } from \"@/components/ui/input\"\n\ntype CardBrand = \"visa\" | \"mastercard\" | \"amex\" | \"discover\" | \"unknown\"\n\ntype BrandSpec = { brand: CardBrand; label: string; test: RegExp; gaps: number[]; lengths: number[]; cvc: number }\n\nconst BRANDS: BrandSpec[] = [\n  { brand: \"amex\", label: \"Amex\", test: /^3[47]/, gaps: [4, 10], lengths: [15], cvc: 4 },\n  { brand: \"visa\", label: \"Visa\", test: /^4/, gaps: [4, 8, 12], lengths: [13, 16, 19], cvc: 3 },\n  { brand: \"mastercard\", label: \"Mastercard\", test: /^(5[1-5]|2(2[2-9]|[3-6]\\d|7[01]|720))/, gaps: [4, 8, 12], lengths: [16], cvc: 3 },\n  { brand: \"discover\", label: \"Discover\", test: /^(6011|65|64[4-9]|622)/, gaps: [4, 8, 12], lengths: [16, 19], cvc: 3 },\n]\nconst UNKNOWN: BrandSpec = { brand: \"unknown\", label: \"Card\", test: /^/, gaps: [4, 8, 12, 16], lengths: [12, 13, 14, 15, 16, 17, 18, 19], cvc: 3 }\n\nconst detect = (digits: string) => BRANDS.find((b) => b.test.test(digits)) ?? UNKNOWN\nconst maxLen = (b: BrandSpec) => Math.max(...b.lengths)\n\nfunction group(digits: string, gaps: number[]) {\n  let out = \"\"\n  for (let i = 0; i < digits.length; i++) {\n    if (gaps.includes(i)) out += \" \"\n    out += digits[i]\n  }\n  return out\n}\n\n/** Luhn (mod 10) checksum, the check digit rule every major card number follows. */\nfunction luhn(digits: string) {\n  let sum = 0\n  for (let i = 0; i < digits.length; i++) {\n    let d = Number(digits[digits.length - 1 - i])\n    if (i % 2 === 1) {\n      d *= 2\n      if (d > 9) d -= 9\n    }\n    sum += d\n  }\n  return digits.length > 0 && sum % 10 === 0\n}\n\nfunction expiryState(digits: string) {\n  if (digits.length < 4) return \"incomplete\" as const\n  const m = Number(digits.slice(0, 2))\n  const y = 2000 + Number(digits.slice(2, 4))\n  if (m < 1 || m > 12) return \"invalid\" as const\n  const now = new Date()\n  if (y < now.getFullYear() || (y === now.getFullYear() && m < now.getMonth() + 1)) return \"expired\" as const\n  return \"ok\" as const\n}\n\n// Keeps the caret after the same number of digits once the value is reformatted.\nfunction caretAfterDigits(formatted: string, count: number) {\n  let pos = 0\n  let seen = 0\n  while (pos < formatted.length && seen < count) {\n    if (/\\d/.test(formatted[pos])) seen++\n    pos++\n  }\n  return pos\n}\n\ntype CreditCardValue = {\n  /** Digits only. */\n  number: string\n  /** \"MM/YY\", or partial while typing. */\n  expiry: string\n  cvc: string\n  brand: CardBrand\n  name: string\n  postalCode: string\n  /** Every shown field is complete and passes its check. */\n  valid: boolean\n}\n\ntype Field = \"name\" | \"number\" | \"expiry\" | \"cvc\" | \"postal\"\n\n/**\n * Card details fields: the number groups by brand (Visa, Mastercard and\n * Discover 4-4-4-4, Amex 4-6-5) and passes a Luhn check, the brand shows as a\n * text badge, expiry formats to MM / YY and advances on its own, and the CVC\n * expects 4 digits for Amex. Fields carry cc-* autocomplete hints and\n * aria-invalid, and errors appear once a field is left. This is UI only: it\n * formats and checks input in the browser and does not process, tokenize or\n * transmit payments. Hand the values to your payment provider's SDK.\n */\ntype CreditCardInputProps = {\n  /** Called on every edit with the parsed values. */\n  onChange?: (value: CreditCardValue) => void\n  defaultValue?: Partial<Pick<CreditCardValue, \"number\" | \"expiry\" | \"cvc\" | \"name\" | \"postalCode\">>\n  /** Show the name on card field. */\n  showName?: boolean\n  /** Show the postal code field. */\n  showPostalCode?: boolean\n  /** Show the accepted brands, the detected one highlighted. */\n  showBrands?: boolean\n  label?: string\n  /** Helper line shown while there is no error. */\n  hint?: React.ReactNode\n  disabled?: boolean\n  className?: string\n}\n\nfunction CreditCardInput({\n  onChange,\n  defaultValue = {},\n  showName = true,\n  showPostalCode = true,\n  showBrands = true,\n  label = \"Card details\",\n  hint = \"Test with 4242 4242 4242 4242 and any future date.\",\n  disabled = false,\n  className,\n}: CreditCardInputProps) {\n  const reduce = useReducedMotion()\n  const uid = React.useId()\n  const [name, setName] = React.useState(defaultValue.name ?? \"\")\n  const [number, setNumber] = React.useState(() => (defaultValue.number ?? \"\").replace(/\\D/g, \"\"))\n  const [expiry, setExpiry] = React.useState(() => (defaultValue.expiry ?? \"\").replace(/\\D/g, \"\").slice(0, 4))\n  const [expiryEager, setExpiryEager] = React.useState(false)\n  const [cvcRaw, setCvc] = React.useState(defaultValue.cvc ?? \"\")\n  const [postal, setPostal] = React.useState(defaultValue.postalCode ?? \"\")\n  const [touched, setTouched] = React.useState<Partial<Record<Field, boolean>>>({})\n  const refs = React.useRef<Partial<Record<Field, HTMLInputElement | null>>>({})\n  const caret = React.useRef<{ field: Field; pos: number } | null>(null)\n  const [, force] = React.useReducer((n: number) => n + 1, 0)\n\n  const spec = detect(number)\n  const brand = number ? spec.brand : \"unknown\"\n  const cvc = cvcRaw.slice(0, spec.cvc)\n  const numberFull = spec.lengths.includes(number.length)\n  const numberAtMax = number.length === maxLen(spec)\n  const exp = expiryState(expiry)\n\n  const issues: Record<Field, string | null> = {\n    name: showName && !name.trim() ? \"Enter the name on the card.\" : null,\n    number: !number\n      ? \"Enter a card number.\"\n      : !numberFull\n        ? \"Your card number is incomplete.\"\n        : !luhn(number)\n          ? \"Your card number is invalid.\"\n          : null,\n    expiry:\n      exp === \"incomplete\" ? \"Your card's expiry date is incomplete.\" : exp === \"invalid\" ? \"Your card's expiry month is invalid.\" : exp === \"expired\" ? \"Your card's expiry date is in the past.\" : null,\n    cvc: cvc.length < spec.cvc ? `Your card's security code is ${spec.cvc} digits.` : null,\n    postal: showPostalCode && postal.trim().length < 3 ? \"Enter a postal code.\" : null,\n  }\n  // A full-length number that fails Luhn is flagged right away; everything else waits for blur.\n  const shown = (f: Field) => Boolean(issues[f]) && (touched[f] || (f === \"number\" && numberAtMax && !luhn(number)) || (f === \"expiry\" && expiry.length === 4))\n  const order: Field[] = [\"name\", \"number\", \"expiry\", \"cvc\", \"postal\"]\n  const firstError = order.find((f) => shown(f))\n  const valid = order.every((f) => !issues[f])\n\n  const latest = React.useRef(onChange)\n  latest.current = onChange\n  const first = React.useRef(true)\n  React.useEffect(() => {\n    if (first.current) {\n      first.current = false\n      return\n    }\n    const ex = expiry.length > 2 ? `${expiry.slice(0, 2)}/${expiry.slice(2)}` : expiry\n    latest.current?.({ number, expiry: ex, cvc, brand, name, postalCode: postal, valid })\n  }, [number, expiry, cvc, brand, name, postal, valid])\n\n  React.useLayoutEffect(() => {\n    const c = caret.current\n    if (!c) return\n    const el = refs.current[c.field]\n    if (el && document.activeElement === el) el.setSelectionRange(c.pos, c.pos)\n    caret.current = null\n  })\n\n  const advance = (to: Field) => {\n    const el = refs.current[to]\n    if (el) {\n      el.focus()\n      el.select()\n    }\n  }\n  const backTo = (to: Field) => (e: React.KeyboardEvent<HTMLInputElement>) => {\n    if (e.key === \"Backspace\" && e.currentTarget.value === \"\" && refs.current[to]) {\n      e.preventDefault()\n      const el = refs.current[to]!\n      el.focus()\n      el.setSelectionRange(el.value.length, el.value.length)\n    }\n  }\n  const blur = (f: Field) => () => setTouched((t) => (t[f] ? t : { ...t, [f]: true }))\n\n  const onNumber = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const el = e.target\n    const type = (e.nativeEvent as InputEvent).inputType ?? \"\"\n    const sel = el.selectionStart ?? el.value.length\n    let before = el.value.slice(0, sel).replace(/\\D/g, \"\").length\n    let digits = el.value.replace(/\\D/g, \"\")\n    if (digits === number && type === \"deleteContentBackward\" && before > 0) {\n      // Backspace on a group space removes the digit before it.\n      digits = digits.slice(0, before - 1) + digits.slice(before)\n      before -= 1\n    }\n    const next = detect(digits)\n    digits = digits.slice(0, maxLen(next))\n    before = Math.min(before, digits.length)\n    caret.current = { field: \"number\", pos: caretAfterDigits(group(digits, next.gaps), before) }\n    setNumber(digits)\n    force()\n    const inserting = type.startsWith(\"insert\")\n    const done = digits.length === maxLen(next) || (digits.length === 16 && next.lengths.includes(16))\n    if (inserting && before === digits.length && done && luhn(digits)) advance(\"expiry\")\n  }\n\n  const onExpiry = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const el = e.target\n    const type = (e.nativeEvent as InputEvent).inputType ?? \"\"\n    const deleting = type.startsWith(\"delete\")\n    const sel = el.selectionStart ?? el.value.length\n    let before = el.value.slice(0, sel).replace(/\\D/g, \"\").length\n    let digits = el.value.replace(/\\D/g, \"\")\n    const atEnd = sel >= el.value.length\n    if (digits === expiry && deleting && !atEnd && before > 0) {\n      digits = digits.slice(0, before - 1) + digits.slice(before)\n      before -= 1\n    }\n    if (digits.length >= 1 && Number(digits[0]) > 1) {\n      digits = \"0\" + digits\n      before += 1\n    }\n    if (digits.length >= 2 && digits[0] === \"1\" && Number(digits[1]) > 2) {\n      digits = digits[0] + digits.slice(2)\n      before = Math.min(before, 1)\n    }\n    digits = digits.slice(0, 4)\n    before = Math.min(before, digits.length)\n    const eager = !deleting && digits.length === 2 && before === 2\n    const text = formatExpiry(digits, eager)\n    caret.current = { field: \"expiry\", pos: eager ? text.length : caretAfterDigits(text, before) }\n    setExpiry(digits)\n    setExpiryEager(eager)\n    force()\n    if (!deleting && digits.length === 4 && before === 4) advance(\"cvc\")\n  }\n\n  const onCvc = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const digits = e.target.value.replace(/\\D/g, \"\").slice(0, spec.cvc)\n    setCvc(digits)\n    const type = (e.nativeEvent as InputEvent).inputType ?? \"\"\n    if (type.startsWith(\"insert\") && digits.length === spec.cvc && showPostalCode) advance(\"postal\")\n  }\n\n  const fieldCls = cn(\n    \"h-11 w-full min-w-0 bg-transparent px-3 font-mono text-sm text-fg tabular-nums outline-none placeholder:font-sans placeholder:text-fg-subtle\",\n    \"aria-invalid:text-danger disabled:cursor-not-allowed disabled:text-fg-muted\"\n  )\n  const errId = `${uid}-error`\n  const described = (f: Field) => (shown(f) && firstError === f ? errId : undefined)\n  const anyShown = order.some((f) => shown(f) && f !== \"name\")\n\n  return (\n    <div data-slot=\"credit-card-input\" className={cn(\"flex w-full max-w-sm min-w-0 flex-col gap-4\", className)}>\n      {showName ? (\n        <div className=\"flex flex-col gap-2\">\n          <label htmlFor={`${uid}-name`} className=\"text-sm font-medium text-fg\">\n            Name on card\n          </label>\n          <input\n            id={`${uid}-name`}\n            ref={(el) => {\n              refs.current.name = el\n            }}\n            value={name}\n            onChange={(e) => setName(e.target.value)}\n            onBlur={blur(\"name\")}\n            autoComplete=\"cc-name\"\n            placeholder=\"Maya Chen\"\n            spellCheck={false}\n            disabled={disabled}\n            aria-invalid={shown(\"name\") || undefined}\n            aria-describedby={described(\"name\")}\n            className={inputVariants({ size: \"default\" })}\n          />\n        </div>\n      ) : null}\n\n      <div role=\"group\" aria-labelledby={`${uid}-label`} className=\"flex flex-col gap-2\">\n        <div className=\"flex flex-wrap items-center justify-between gap-2\">\n          <span id={`${uid}-label`} className=\"text-sm font-medium text-fg\">\n            {label}\n          </span>\n          {showBrands ? (\n            <ul aria-label=\"Accepted cards\" className=\"flex items-center gap-1\">\n              {BRANDS.map((b) => (\n                <li\n                  key={b.brand}\n                  aria-current={brand === b.brand || undefined}\n                  className={cn(\n                    \"rounded-md px-1.5 py-0.5 text-2xs font-semibold tracking-wide transition-[color,background-color,opacity] duration-[140ms] ease-hairline\",\n                    brand === b.brand ? \"bg-accent-soft text-accent-fg\" : \"text-fg-subtle\",\n                    brand !== \"unknown\" && brand !== b.brand && \"opacity-50\"\n                  )}\n                >\n                  {b.label}\n                </li>\n              ))}\n            </ul>\n          ) : null}\n        </div>\n\n        <div\n          className={cn(\n            \"overflow-hidden rounded-lg border bg-surface shadow-xs transition-[border-color,box-shadow] duration-[140ms] ease-hairline\",\n            anyShown ? \"border-danger\" : \"border-border hover:border-border-strong\",\n            anyShown\n              ? \"focus-within:shadow-[0_0_0_3px_color-mix(in_oklch,var(--danger)_14%,transparent)]\"\n              : \"focus-within:border-accent focus-within:shadow-[0_0_0_3px_var(--accent-soft)]\",\n            disabled && \"bg-sunken shadow-none\"\n          )}\n        >\n          <div className=\"relative\">\n            <label htmlFor={`${uid}-number`} className=\"sr-only\">\n              Card number\n            </label>\n            <input\n              id={`${uid}-number`}\n              ref={(el) => {\n                refs.current.number = el\n              }}\n              value={group(number, spec.gaps)}\n              onChange={onNumber}\n              onBlur={blur(\"number\")}\n              inputMode=\"numeric\"\n              autoComplete=\"cc-number\"\n              placeholder={spec.brand === \"amex\" ? \"1234 123456 12345\" : \"1234 1234 1234 1234\"}\n              spellCheck={false}\n              disabled={disabled}\n              aria-invalid={shown(\"number\") || undefined}\n              aria-describedby={described(\"number\")}\n              className={cn(fieldCls, \"pr-28\")}\n            />\n            <span className=\"pointer-events-none absolute inset-y-0 right-2.5 flex items-center\">\n              <AnimatePresence mode=\"popLayout\" initial={false}>\n                <motion.span\n                  key={brand}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, scale: 0.9 }}\n                  animate={{ opacity: 1, y: 0, scale: 1 }}\n                  exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -6, scale: 0.9, transition: { duration: 0.12 } }}\n                  transition={reduce ? { duration: 0 } : { type: \"spring\", bounce: 0.18, duration: 0.42 }}\n                  className=\"flex\"\n                >\n                  {brand === \"unknown\" ? (\n                    <CreditCardIcon aria-hidden className=\"size-4 text-fg-subtle\" />\n                  ) : (\n                    <span className=\"rounded-md border border-border bg-sunken px-1.5 py-0.5 text-2xs font-semibold tracking-wide text-fg uppercase\">\n                      {spec.label}\n                    </span>\n                  )}\n                </motion.span>\n              </AnimatePresence>\n            </span>\n            <span className=\"sr-only\" aria-live=\"polite\">\n              {brand === \"unknown\" ? \"\" : `${spec.label} detected`}\n            </span>\n          </div>\n\n          <div className={cn(\"grid border-t border-border\", showPostalCode ? \"grid-cols-3\" : \"grid-cols-2\")}>\n            <div className=\"min-w-0\">\n              <label htmlFor={`${uid}-exp`} className=\"sr-only\">\n                Expiry date, month and year\n              </label>\n              <input\n                id={`${uid}-exp`}\n                ref={(el) => {\n                  refs.current.expiry = el\n                }}\n                value={formatExpiry(expiry, expiryEager)}\n                onChange={onExpiry}\n                onKeyDown={backTo(\"number\")}\n                onBlur={() => {\n                  blur(\"expiry\")()\n                  setExpiryEager(false)\n                }}\n                inputMode=\"numeric\"\n                autoComplete=\"cc-exp\"\n                placeholder=\"MM / YY\"\n                spellCheck={false}\n                disabled={disabled}\n                aria-invalid={shown(\"expiry\") || undefined}\n                aria-describedby={described(\"expiry\")}\n                className={fieldCls}\n              />\n            </div>\n            <div className=\"min-w-0 border-l border-border\">\n              <label htmlFor={`${uid}-cvc`} className=\"sr-only\">\n                Security code\n              </label>\n              <input\n                id={`${uid}-cvc`}\n                ref={(el) => {\n                  refs.current.cvc = el\n                }}\n                value={cvc}\n                onChange={onCvc}\n                onKeyDown={backTo(\"expiry\")}\n                onBlur={blur(\"cvc\")}\n                inputMode=\"numeric\"\n                autoComplete=\"cc-csc\"\n                placeholder={spec.cvc === 4 ? \"CVC 4\" : \"CVC\"}\n                spellCheck={false}\n                disabled={disabled}\n                aria-invalid={shown(\"cvc\") || undefined}\n                aria-describedby={described(\"cvc\")}\n                className={fieldCls}\n              />\n            </div>\n            {showPostalCode ? (\n              <div className=\"min-w-0 border-l border-border\">\n                <label htmlFor={`${uid}-zip`} className=\"sr-only\">\n                  Postal code\n                </label>\n                <input\n                  id={`${uid}-zip`}\n                  ref={(el) => {\n                    refs.current.postal = el\n                  }}\n                  value={postal}\n                  onChange={(e) => setPostal(e.target.value.toUpperCase().replace(/[^A-Z0-9 -]/g, \"\").slice(0, 10))}\n                  onKeyDown={backTo(\"cvc\")}\n                  onBlur={blur(\"postal\")}\n                  autoComplete=\"postal-code\"\n                  placeholder=\"ZIP\"\n                  spellCheck={false}\n                  disabled={disabled}\n                  aria-invalid={shown(\"postal\") || undefined}\n                  aria-describedby={described(\"postal\")}\n                  className={fieldCls}\n                />\n              </div>\n            ) : null}\n          </div>\n        </div>\n\n        <p id={errId} aria-live=\"polite\" className={cn(\"flex min-h-5 items-start gap-1.5 text-xs\", firstError ? \"text-danger\" : \"text-fg-subtle\")}>\n          {firstError ? (\n            <>\n              <CircleAlertIcon aria-hidden className=\"mt-0.5 size-3.5 shrink-0\" />\n              <span>{issues[firstError]}</span>\n            </>\n          ) : valid ? (\n            <span className=\"text-success\">Card details look complete.</span>\n          ) : (\n            <span>{hint}</span>\n          )}\n        </p>\n      </div>\n    </div>\n  )\n}\n\nfunction formatExpiry(digits: string, eager: boolean) {\n  if (digits.length < 2) return digits\n  if (digits.length === 2) return eager ? `${digits} / ` : digits\n  return `${digits.slice(0, 2)} / ${digits.slice(2)}`\n}\n\nexport { CreditCardInput, luhn }\nexport type { CreditCardInputProps, CreditCardValue, CardBrand }\n"
    }
  ]
}
