{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"card-input","type":"registry:ui","title":"Card Input","description":"Card number, expiry and CVC in one well: digits group as you type, the brand is detected from the prefix and its mark flips in, focus advances field to field, and it stacks to two rows when narrow.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/field"],"files":[{"path":"registry/geturui/ui/card-input.tsx","type":"registry:ui","target":"components/ui/card-input.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Check } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Field, fieldWellClasses, type FieldSize } from \"@/components/ui/field\";\n\nexport type CardBrand = \"visa\" | \"mastercard\" | \"amex\" | \"discover\" | \"unknown\";\n\nexport interface CardValue {\n  /** Digits only. */\n  number: string;\n  /** Digits only, MMYY. */\n  expiry: string;\n  /** Digits only. */\n  cvc: string;\n}\n\nexport interface CardMeta {\n  brand: CardBrand;\n  numberComplete: boolean;\n  /** Full length and passes the Luhn check. */\n  numberValid: boolean;\n  /** A real month that hasn't passed. */\n  expiryValid: boolean;\n  cvcValid: boolean;\n  complete: boolean;\n}\n\nexport interface CardInputProps {\n  value?: CardValue;\n  defaultValue?: Partial<CardValue>;\n  onValueChange?: (value: CardValue, meta: CardMeta) => void;\n  /** @default \"md\" */\n  size?: FieldSize;\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  required?: boolean;\n  disabled?: boolean;\n  labelAside?: React.ReactNode;\n  /** Prefix for the three inputs' name attributes: `${name}-number`, `${name}-expiry`, `${name}-cvc`. */\n  name?: string;\n  id?: string;\n  /** Classes for the well. */\n  className?: string;\n  /** Classes for the outer grid. */\n  wrapperClassName?: string;\n}\n\nconst brandSpec: Record<CardBrand, { name: string; groups: number[]; cvc: number; tone: string }> = {\n  visa: { name: \"Visa\", groups: [4, 4, 4, 4], cvc: 3, tone: \"bg-info-soft text-info\" },\n  mastercard: { name: \"Mastercard\", groups: [4, 4, 4, 4], cvc: 3, tone: \"bg-warning-soft text-warning\" },\n  amex: { name: \"American Express\", groups: [4, 6, 5], cvc: 4, tone: \"bg-success-soft text-success\" },\n  discover: { name: \"Discover\", groups: [4, 4, 4, 4], cvc: 3, tone: \"bg-ember-soft text-ember-ink\" },\n  unknown: { name: \"Card\", groups: [4, 4, 4, 4], cvc: 3, tone: \"bg-surface text-ink-subtle\" },\n};\n\n/** Brand from the leading digits (IIN prefix). */\nexport function detectCardBrand(digits: string): CardBrand {\n  if (/^3[47]/.test(digits)) return \"amex\";\n  if (/^4/.test(digits)) return \"visa\";\n  if (/^(5[1-5]|222[1-9]|22[3-9]|2[3-6]|27[01]|2720)/.test(digits)) return \"mastercard\";\n  if (/^(6011|65|64[4-9])/.test(digits)) return \"discover\";\n  return \"unknown\";\n}\n\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 formatNumber(digits: string, groups: number[]) {\n  const out: string[] = [];\n  let i = 0;\n  for (const g of groups) {\n    if (i >= digits.length) break;\n    out.push(digits.slice(i, i + g));\n    i += g;\n  }\n  return out.join(\" \");\n}\n\nfunction formatExpiry(digits: string) {\n  if (digits.length <= 2) return digits;\n  return `${digits.slice(0, 2)} / ${digits.slice(2)}`;\n}\n\nconst onlyDigits = (s: string) => s.replace(/\\D/g, \"\");\n\nfunction computeMeta(v: CardValue): CardMeta {\n  const brand = detectCardBrand(v.number);\n  const spec = brandSpec[brand];\n  const len = spec.groups.reduce((a, b) => a + b, 0);\n  const numberComplete = v.number.length === len;\n  const numberValid = numberComplete && luhn(v.number);\n  let expiryValid = false;\n  if (v.expiry.length === 4) {\n    const m = Number(v.expiry.slice(0, 2));\n    const y = 2000 + Number(v.expiry.slice(2));\n    const now = new Date();\n    expiryValid = m >= 1 && m <= 12 && (y > now.getFullYear() || (y === now.getFullYear() && m >= now.getMonth() + 1));\n  }\n  const cvcValid = v.cvc.length === spec.cvc;\n  return { brand, numberComplete, numberValid, expiryValid, cvcValid, complete: numberValid && expiryValid && cvcValid };\n}\n\n/** Abstract per-brand mark on a small card chip. Generic shapes only, no real logos. */\nfunction BrandMark({ brand }: { brand: CardBrand }) {\n  return (\n    <svg viewBox=\"0 0 28 20\" className=\"size-full\" aria-hidden fill=\"currentColor\">\n      {brand === \"unknown\" && (\n        <>\n          <rect x=\"1.5\" y=\"2.5\" width=\"25\" height=\"15\" rx=\"3\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" />\n          <rect x=\"5\" y=\"7\" width=\"5\" height=\"4\" rx=\"1\" />\n          <rect x=\"5\" y=\"13\" width=\"12\" height=\"1.5\" rx=\"0.75\" />\n        </>\n      )}\n      {brand === \"visa\" && (\n        <>\n          <path d=\"M7 14 L10 6 H12.5 L9.5 14 Z\" />\n          <path d=\"M12 14 L15 6 H17.5 L14.5 14 Z\" opacity=\"0.75\" />\n          <path d=\"M17 14 L20 6 H22.5 L19.5 14 Z\" opacity=\"0.5\" />\n        </>\n      )}\n      {brand === \"mastercard\" && (\n        <>\n          <rect x=\"7.5\" y=\"5.5\" width=\"8\" height=\"8\" rx=\"1.5\" transform=\"rotate(45 11.5 9.5)\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.6\" />\n          <rect x=\"12.5\" y=\"6.5\" width=\"8\" height=\"8\" rx=\"1.5\" transform=\"rotate(45 16.5 10.5)\" opacity=\"0.6\" />\n        </>\n      )}\n      {brand === \"amex\" && (\n        <>\n          <rect x=\"8\" y=\"4\" width=\"5\" height=\"5\" rx=\"1\" />\n          <rect x=\"15\" y=\"4\" width=\"5\" height=\"5\" rx=\"1\" opacity=\"0.6\" />\n          <rect x=\"8\" y=\"11\" width=\"5\" height=\"5\" rx=\"1\" opacity=\"0.6\" />\n          <rect x=\"15\" y=\"11\" width=\"5\" height=\"5\" rx=\"1\" />\n        </>\n      )}\n      {brand === \"discover\" && (\n        <>\n          <circle cx=\"16.5\" cy=\"10\" r=\"4.5\" />\n          <rect x=\"5\" y=\"9.25\" width=\"9\" height=\"1.5\" rx=\"0.75\" opacity=\"0.7\" />\n        </>\n      )}\n    </svg>\n  );\n}\n\nconst rowHeight: Record<FieldSize, string> = { sm: \"h-[30px]\", md: \"h-[38px]\", lg: \"h-[46px]\" };\n\n/**\n * Card number, expiry and CVC in one well. Numbers group as you type (4-6-5 for Amex-style cards), the brand is\n * detected from the prefix and its mark flips in, and focus advances field to field. Stacks to two rows when narrow.\n */\nexport function CardInput({\n  value,\n  defaultValue,\n  onValueChange,\n  size = \"md\",\n  label,\n  hint,\n  error,\n  required,\n  disabled,\n  labelAside,\n  name,\n  id,\n  className,\n  wrapperClassName,\n}: CardInputProps) {\n  const reduce = useReducedMotion();\n  const [inner, setInner] = React.useState<CardValue>(() => ({\n    number: onlyDigits(defaultValue?.number ?? \"\"),\n    expiry: onlyDigits(defaultValue?.expiry ?? \"\"),\n    cvc: onlyDigits(defaultValue?.cvc ?? \"\"),\n  }));\n  const card = value ?? inner;\n  const brand = detectCardBrand(card.number);\n  const spec = brandSpec[brand];\n  const maxLen = spec.groups.reduce((a, b) => a + b, 0);\n  const numberValid = card.number.length === maxLen && luhn(card.number);\n\n  const numberRef = React.useRef<HTMLInputElement>(null);\n  const expiryRef = React.useRef<HTMLInputElement>(null);\n  const cvcRef = React.useRef<HTMLInputElement>(null);\n  const pendingCaret = React.useRef<{ el: HTMLInputElement; digits: number } | null>(null);\n\n  const update = (patch: Partial<CardValue>) => {\n    const next = { ...card, ...patch };\n    if (value === undefined) setInner(next);\n    onValueChange?.(next, computeMeta(next));\n  };\n\n  // Put the caret back after the same number of digits once the formatted text re-renders.\n  React.useLayoutEffect(() => {\n    const p = pendingCaret.current;\n    if (!p) return;\n    pendingCaret.current = null;\n    if (document.activeElement !== p.el) return;\n    let seen = 0;\n    let pos = 0;\n    const text = p.el.value;\n    while (pos < text.length && seen < p.digits) {\n      if (/\\d/.test(text[pos])) seen++;\n      pos++;\n    }\n    p.el.setSelectionRange(pos, pos);\n  });\n\n  const digitsBeforeCaret = (el: HTMLInputElement) => onlyDigits(el.value.slice(0, el.selectionStart ?? el.value.length)).length;\n\n  const rh = rowHeight[size];\n  const formattedNumber = formatNumber(card.number, spec.groups);\n  const placeholderNumber = brand === \"amex\" ? \"3782 822463 10005\" : \"1234 1234 1234 1234\";\n\n  return (\n    <Field label={label} hint={hint} error={error} required={required} labelAside={labelAside} id={id} disabled={disabled} className={wrapperClassName}>\n      {(ids) => (\n        <div className=\"@container\">\n          <div\n            role=\"group\"\n            aria-labelledby={label ? ids.labelId : undefined}\n            aria-describedby={ids.describedBy}\n            className={fieldWellClasses({\n              size,\n              invalid: ids.invalid,\n              disabled,\n              className: cn(\"flex-col items-stretch gap-0 px-0 @sm:flex-row\", className),\n            })}\n          >\n            <div className={cn(\"flex min-w-0 shrink-0 items-center gap-2.5 pl-2.5 pr-3 @sm:flex-1\", rh, \"@sm:h-auto\")}>\n              <span className=\"relative h-5 w-7 shrink-0 [perspective:200px]\" role=\"img\" aria-label={spec.name}>\n                <AnimatePresence initial={false} mode=\"popLayout\">\n                  <motion.span\n                    key={brand}\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, rotateY: -90, scale: 0.8, filter: \"blur(4px)\" }}\n                    animate={{ opacity: 1, rotateY: 0, scale: 1, filter: \"blur(0px)\" }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, rotateY: 90, scale: 0.8, filter: \"blur(4px)\" }}\n                    transition={{ type: \"spring\", stiffness: 360, damping: 26 }}\n                    className={cn(\"absolute inset-0 grid place-items-center rounded-[4px] p-[1px]\", spec.tone)}\n                  >\n                    <BrandMark brand={brand} />\n                  </motion.span>\n                </AnimatePresence>\n              </span>\n              <span className=\"sr-only\" aria-live=\"polite\">\n                {brand !== \"unknown\" ? `${spec.name} card detected` : \"\"}\n              </span>\n              <input\n                ref={numberRef}\n                id={ids.id}\n                name={name ? `${name}-number` : undefined}\n                type=\"text\"\n                inputMode=\"numeric\"\n                autoComplete=\"cc-number\"\n                aria-label={label ? undefined : \"Card number\"}\n                aria-invalid={ids.invalid || undefined}\n                aria-describedby={ids.describedBy}\n                required={required}\n                disabled={disabled}\n                placeholder={placeholderNumber}\n                value={formattedNumber}\n                onChange={(e) => {\n                  const el = e.currentTarget;\n                  const raw = onlyDigits(el.value);\n                  const b = detectCardBrand(raw);\n                  const len = brandSpec[b].groups.reduce((a, c) => a + c, 0);\n                  const digits = raw.slice(0, len);\n                  pendingCaret.current = { el, digits: Math.min(digitsBeforeCaret(el), digits.length) };\n                  update({ number: digits, cvc: card.cvc.slice(0, brandSpec[b].cvc) });\n                  if (digits.length === len && (el.selectionStart ?? 0) >= el.value.length && luhn(digits)) expiryRef.current?.focus();\n                }}\n                className=\"min-w-0 flex-1 self-stretch bg-transparent tabular-nums text-ink outline-none focus-visible:shadow-none placeholder:text-ink-subtle\"\n              />\n              <AnimatePresence initial={false}>\n                {numberValid && (\n                  <motion.span\n                    initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.4 }}\n                    transition={{ type: \"spring\", stiffness: 520, damping: 24 }}\n                    className=\"shrink-0 text-success\"\n                  >\n                    <Check className=\"size-4\" aria-label=\"Card number looks valid\" />\n                  </motion.span>\n                )}\n              </AnimatePresence>\n            </div>\n            <div className={cn(\"flex items-center border-t border-border-strong @sm:border-t-0\", rh, \"@sm:h-auto\")}>\n              <input\n                ref={expiryRef}\n                name={name ? `${name}-expiry` : undefined}\n                type=\"text\"\n                inputMode=\"numeric\"\n                autoComplete=\"cc-exp\"\n                aria-label=\"Expiry date, month and year\"\n                aria-invalid={ids.invalid || undefined}\n                aria-describedby={ids.describedBy}\n                required={required}\n                disabled={disabled}\n                placeholder=\"MM / YY\"\n                value={formatExpiry(card.expiry)}\n                onChange={(e) => {\n                  const el = e.currentTarget;\n                  const typed = onlyDigits(el.value).slice(0, 4);\n                  // \"4\" → \"04\": a lone first digit above 1 can only be a one-digit month.\n                  const digits = typed.length === 1 && Number(typed) > 1 ? `0${typed}` : typed;\n                  pendingCaret.current = { el, digits: Math.min(digitsBeforeCaret(el) + (digits.length - typed.length), digits.length) };\n                  update({ expiry: digits });\n                  if (digits.length === 4 && (el.selectionStart ?? 0) >= el.value.length) cvcRef.current?.focus();\n                }}\n                onKeyDown={(e) => {\n                  if (e.key === \"Backspace\" && !e.currentTarget.value) {\n                    e.preventDefault();\n                    numberRef.current?.focus();\n                  }\n                }}\n                className=\"min-w-0 flex-1 self-stretch bg-transparent px-3 tabular-nums text-ink outline-none focus-visible:shadow-none placeholder:text-ink-subtle @sm:w-[78px] @sm:flex-none @sm:px-2\"\n              />\n              <span aria-hidden className=\"h-full w-px bg-border-strong @sm:hidden\" />\n              <input\n                ref={cvcRef}\n                name={name ? `${name}-cvc` : undefined}\n                type=\"text\"\n                inputMode=\"numeric\"\n                autoComplete=\"cc-csc\"\n                aria-label={`Security code, ${spec.cvc} digits`}\n                aria-invalid={ids.invalid || undefined}\n                aria-describedby={ids.describedBy}\n                required={required}\n                disabled={disabled}\n                placeholder={spec.cvc === 4 ? \"CVC4\" : \"CVC\"}\n                value={card.cvc}\n                onChange={(e) => update({ cvc: onlyDigits(e.currentTarget.value).slice(0, spec.cvc) })}\n                onKeyDown={(e) => {\n                  if (e.key === \"Backspace\" && !e.currentTarget.value) {\n                    e.preventDefault();\n                    expiryRef.current?.focus();\n                  }\n                }}\n                className=\"min-w-0 flex-1 self-stretch bg-transparent px-3 tabular-nums text-ink outline-none focus-visible:shadow-none placeholder:text-ink-subtle @sm:w-[52px] @sm:flex-none @sm:pl-2 @sm:pr-3\"\n              />\n            </div>\n          </div>\n        </div>\n      )}\n    </Field>\n  );\n}\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","payment","credit card","checkout","billing","input","form"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/card-input"}}