{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "card-field",
  "type": "registry:ui",
  "title": "Card Field",
  "description": "One card input that formats, detects the network, validates and advances itself.",
  "categories": [
    "commerce"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/card-field.tsx",
      "content": "\"use client\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert, CreditCard } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\n/* -------------------------------------------------------------------------------------------------\n * Card rules. Formatting and validation only: this component never sends card data anywhere.\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CardBrand = \"visa\" | \"mastercard\" | \"amex\" | \"discover\" | \"unknown\";\n\nexport type CardValue = {\n  /** Digits only. */\n  number: string;\n  /** Digits only, MMYY. */\n  expiry: string;\n  cvc: string;\n};\n\nconst brandName: Record<CardBrand, string> = { visa: \"Visa\", mastercard: \"Mastercard\", amex: \"American Express\", discover: \"Discover\", unknown: \"\" };\nconst brandTag: Record<CardBrand, string> = { visa: \"VISA\", mastercard: \"MC\", amex: \"AMEX\", discover: \"DISC\", unknown: \"\" };\n\n/** The card network from the leading digits, as soon as they are enough to tell. */\nexport function detectCardBrand(digits: string): CardBrand {\n  if (/^4/.test(digits)) return \"visa\";\n  if (/^3[47]/.test(digits)) return \"amex\";\n  if (/^(5[1-5]|2(2[2-9]|[3-6]\\d|7[01]|720))/.test(digits)) return \"mastercard\";\n  if (/^(6011|65|64[4-9])/.test(digits)) return \"discover\";\n  return \"unknown\";\n}\n\nconst numberLength = (brand: CardBrand) => (brand === \"amex\" ? 15 : brand === \"unknown\" ? 19 : 16);\nconst cvcLength = (brand: CardBrand) => (brand === \"amex\" ? 4 : 3);\n\n/** The Luhn checksum every card number carries. */\nexport function isValidCardNumber(digits: string) {\n  if (digits.length < 12) return false;\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 sum % 10 === 0;\n}\n\n/** Groups digits the way they are printed on the card: 4-6-5 for Amex, fours for the rest. */\nexport function formatCardNumber(digits: string, brand = detectCardBrand(digits)) {\n  const groups = brand === \"amex\" ? [4, 6, 5] : [4, 4, 4, 4, 3];\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\nconst noop = () => () => {};\n// The first day of this month, as a stable number so the store snapshot doesn't change every render.\nconst currentMonth = () => {\n  const d = new Date();\n  return new Date(d.getFullYear(), d.getMonth(), 1).getTime();\n};\n\nconst formatExpiry = (d: string) => (d.length > 2 ? `${d.slice(0, 2)} / ${d.slice(2)}` : d);\n\ntype Segment = \"number\" | \"expiry\" | \"cvc\";\n\nfunction numberComplete(digits: string, brand: CardBrand) {\n  return brand === \"unknown\" ? digits.length >= 16 : digits.length === numberLength(brand);\n}\n\n/** The first thing wrong with each segment, or \"\" when it is fine. `strict` also flags incomplete input. */\nfunction problems(value: CardValue, brand: CardBrand, now: Date | null, strict: Record<Segment, boolean>): Record<Segment, string> {\n  const { number, expiry, cvc } = value;\n  const out: Record<Segment, string> = { number: \"\", expiry: \"\", cvc: \"\" };\n\n  if (numberComplete(number, brand) && !isValidCardNumber(number)) out.number = \"That card number isn’t valid. Check the digits.\";\n  else if (strict.number && !numberComplete(number, brand)) out.number = number ? \"The card number is incomplete.\" : \"Enter a card number.\";\n\n  const month = Number(expiry.slice(0, 2));\n  if (expiry.length >= 2 && (month < 1 || month > 12)) out.expiry = \"Enter a month from 01 to 12.\";\n  else if (expiry.length === 4 && now) {\n    const year = 2000 + Number(expiry.slice(2));\n    const end = new Date(year, month, 1);\n    if (end <= now) out.expiry = `That card expired in ${expiry.slice(0, 2)}/${expiry.slice(2)}.`;\n    else if (year > now.getFullYear() + 20) out.expiry = \"Check the expiry year.\";\n  } else if (strict.expiry && expiry.length < 4) out.expiry = expiry ? \"The expiry date is incomplete.\" : \"Enter the expiry date.\";\n\n  if (strict.cvc && cvc.length < cvcLength(brand)) out.cvc = cvc ? \"The security code is incomplete.\" : \"Enter the security code.\";\n  return out;\n}\n\n/* -------------------------------------------------------------------------------------------------\n * CardField\n * -----------------------------------------------------------------------------------------------*/\n\nexport type CardFieldProps = Omit<React.ComponentProps<\"div\">, \"defaultValue\" | \"onChange\"> & {\n  value?: CardValue;\n  defaultValue?: CardValue;\n  onValueChange?: (value: CardValue) => void;\n  /** Called when the whole card becomes valid or stops being valid. */\n  onCompleteChange?: (complete: boolean, details: { brand: CardBrand; last4: string }) => void;\n  /** Visible label above the field. */\n  label?: React.ReactNode;\n  /** An error from outside, like a decline. Clear it when the card changes. */\n  error?: string;\n  disabled?: boolean;\n  /** Prefix for the three inputs' names when submitted in a form. */\n  name?: string;\n};\n\nconst empty: CardValue = { number: \"\", expiry: \"\", cvc: \"\" };\n\nexport function CardField({\n  value: valueProp,\n  defaultValue = empty,\n  onValueChange,\n  onCompleteChange,\n  label = \"Card information\",\n  error: externalError,\n  disabled = false,\n  name,\n  className,\n  ...rest\n}: CardFieldProps) {\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const reduce = useReducedMotion();\n  const id = useId();\n  const brand = detectCardBrand(value.number);\n  const [focus, setFocus] = useState<Segment | null>(null);\n  const [touched, setTouched] = useState<Record<Segment, boolean>>({ number: false, expiry: false, cvc: false });\n  // The clock is only read on the client, so server and client render the same markup.\n  const month = useSyncExternalStore(noop, currentMonth, () => null);\n  const now = month == null ? null : new Date(month);\n\n  const numberRef = useRef<HTMLInputElement>(null);\n  const expiryRef = useRef<HTMLInputElement>(null);\n  const cvcRef = useRef<HTMLInputElement>(null);\n  const caret = useRef<{ el: HTMLInputElement; pos: number } | null>(null);\n  useLayoutEffect(() => {\n    const c = caret.current;\n    caret.current = null;\n    if (c && document.activeElement === c.el) c.el.setSelectionRange(c.pos, c.pos);\n  });\n\n  const issues = problems(value, brand, now, touched);\n  const order: Segment[] = [\"number\", \"expiry\", \"cvc\"];\n  const first = order.find((s) => issues[s]);\n  const message = externalError || (first ? issues[first] : \"\");\n  const complete =\n    numberComplete(value.number, brand) &&\n    isValidCardNumber(value.number) &&\n    value.expiry.length === 4 &&\n    value.cvc.length === cvcLength(brand) &&\n    !problems(value, brand, now, { number: true, expiry: true, cvc: true }).expiry;\n\n  const completeRef = useRef(false);\n  useEffect(() => {\n    if (completeRef.current === complete) return;\n    completeRef.current = complete;\n    onCompleteChange?.(complete, { brand, last4: value.number.slice(-4) });\n  }, [complete, brand, value.number, onCompleteChange]);\n\n  const update = (patch: Partial<CardValue>) => setValue({ ...value, ...patch });\n  const move = (to: Segment, at: \"start\" | \"end\") => {\n    const el = (to === \"number\" ? numberRef : to === \"expiry\" ? expiryRef : cvcRef).current;\n    if (!el) return;\n    el.focus();\n    const pos = at === \"start\" ? 0 : el.value.length;\n    el.setSelectionRange(pos, pos);\n  };\n\n  // Arrow keys and Backspace cross the seams between segments, like one long field.\n  const seams = (e: React.KeyboardEvent<HTMLInputElement>) => {\n    const el = e.currentTarget;\n    const seg = el.dataset.segment as Segment;\n    const i = order.indexOf(seg);\n    const atStart = el.selectionStart === 0 && el.selectionEnd === 0;\n    const atEnd = el.selectionStart === el.value.length;\n    if ((e.key === \"Backspace\" && atStart) || (e.key === \"ArrowLeft\" && atStart)) {\n      if (i > 0) {\n        e.preventDefault();\n        move(order[i - 1], \"end\");\n      }\n    } else if (e.key === \"ArrowRight\" && atEnd && i < order.length - 1) {\n      e.preventDefault();\n      move(order[i + 1], \"start\");\n    }\n  };\n\n  const onNumber = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const el = e.target;\n    const raw = el.value;\n    const before = raw.slice(0, el.selectionStart ?? raw.length).replace(/\\D/g, \"\").length;\n    const nextBrand = detectCardBrand(raw.replace(/\\D/g, \"\"));\n    const digits = raw.replace(/\\D/g, \"\").slice(0, numberLength(nextBrand));\n    const formatted = formatCardNumber(digits, nextBrand);\n    // Keep the caret after the same digit it followed, whatever spaces were added or removed.\n    let pos = 0;\n    for (let seen = 0; pos < formatted.length && seen < before; pos++) if (/\\d/.test(formatted[pos])) seen++;\n    caret.current = { el, pos };\n    // A shorter security code limit (switching away from Amex) trims the CVC too.\n    update({ number: digits, cvc: value.cvc.slice(0, cvcLength(nextBrand)) });\n    if (numberComplete(digits, nextBrand) && isValidCardNumber(digits) && pos === formatted.length) move(\"expiry\", \"start\");\n    else if (numberComplete(digits, nextBrand) && !isValidCardNumber(digits)) setTouched((t) => ({ ...t, number: true }));\n  };\n\n  const onExpiry = (e: React.ChangeEvent<HTMLInputElement>) => {\n    const raw = e.target.value;\n    let digits = raw.replace(/\\D/g, \"\");\n    // Deleting the \" / \" separator deletes the digit before it.\n    if (digits === value.expiry && raw.length < formatExpiry(value.expiry).length) digits = digits.slice(0, -1);\n    // A first digit of 2–9 can only be a single-digit month, so pad it; \"1/\" becomes \"01\".\n    if (digits.length === 1 && Number(digits) > 1) digits = `0${digits}`;\n    if (digits.length === 1 && raw.includes(\"/\")) digits = `0${digits}`;\n    digits = digits.slice(0, 4);\n    caret.current = null;\n    update({ expiry: digits });\n    if (digits.length === 4) {\n      const p = problems({ ...value, expiry: digits }, brand, now, touched);\n      if (!p.expiry) move(\"cvc\", \"start\");\n      else setTouched((t) => ({ ...t, expiry: true }));\n    }\n  };\n\n  const onCvc = (e: React.ChangeEvent<HTMLInputElement>) => update({ cvc: e.target.value.replace(/\\D/g, \"\").slice(0, cvcLength(brand)) });\n\n  const segmentClass = (seg: Segment) =>\n    cn(\n      \"tabular h-9 min-w-0 bg-transparent text-base outline-none placeholder:text-fg-4 sm:text-[13px]\",\n      \"disabled:cursor-not-allowed\",\n      issues[seg] ? \"text-danger\" : \"text-fg\",\n    );\n  const invalid = !!message;\n  const errorId = `${id}-error`;\n  const describe = (seg: Segment) => (externalError || (first === seg && issues[seg]) ? errorId : undefined);\n  const blur = (seg: Segment) => () => {\n    setFocus((f) => (f === seg ? null : f));\n    // Only judge a segment once it has something in it, or on the way out of the whole field.\n    setTouched((t) => ({ ...t, [seg]: t[seg] || (seg === \"number\" ? value.number : seg === \"expiry\" ? value.expiry : value.cvc).length > 0 }));\n  };\n\n  return (\n    <div className={cn(\"@container flex flex-col gap-1.5\", className)} data-complete={complete ? \"\" : undefined} {...rest}>\n      {label && (\n        <span id={`${id}-label`} className=\"text-[12.5px] font-medium text-fg-2\">\n          {label}\n        </span>\n      )}\n      <div\n        role=\"group\"\n        aria-labelledby={label ? `${id}-label` : undefined}\n        data-invalid={invalid ? \"\" : undefined}\n        data-disabled={disabled ? \"\" : undefined}\n        className={cn(\n          \"flex flex-wrap items-center overflow-hidden rounded-lg border bg-raised shadow-[var(--shadow)]\",\n          \"transition-[border-color,box-shadow] duration-150\",\n          invalid\n            ? \"border-danger/70 focus-within:ring-3 focus-within:ring-danger/15\"\n            : \"border-line-2 hover:border-fg-4 focus-within:border-fg-4 focus-within:ring-3 focus-within:ring-fg/10\",\n          disabled && \"pointer-events-none opacity-50\",\n        )}\n      >\n        <div className=\"flex min-w-0 basis-full items-center gap-2.5 pl-2.5 pr-2 @min-[340px]:flex-1 @min-[340px]:basis-0\">\n          <BrandMark brand={brand} back={focus === \"cvc\"} reduce={!!reduce} />\n          <input\n            ref={numberRef}\n            data-segment=\"number\"\n            name={name ? `${name}-number` : undefined}\n            value={formatCardNumber(value.number, brand)}\n            onChange={onNumber}\n            onKeyDown={seams}\n            onFocus={() => setFocus(\"number\")}\n            onBlur={blur(\"number\")}\n            disabled={disabled}\n            placeholder=\"1234 1234 1234 1234\"\n            aria-label={brand === \"unknown\" ? \"Card number\" : `Card number, ${brandName[brand]}`}\n            aria-invalid={!!issues.number || !!externalError || undefined}\n            aria-describedby={describe(\"number\")}\n            inputMode=\"numeric\"\n            autoComplete=\"cc-number\"\n            autoCorrect=\"off\"\n            spellCheck={false}\n            className={cn(segmentClass(\"number\"), \"flex-1 tracking-[0.01em]\")}\n          />\n          {/* A tick draws once every segment is valid. */}\n          <span aria-hidden className=\"grid size-4 shrink-0 place-items-center text-success\">\n            <AnimatePresence>\n              {complete && !externalError && (\n                <motion.svg\n                  key=\"ok\"\n                  width=\"16\"\n                  height=\"16\"\n                  viewBox=\"0 0 16 16\"\n                  fill=\"none\"\n                  stroke=\"currentColor\"\n                  strokeWidth=\"1.5\"\n                  strokeLinecap=\"round\"\n                  strokeLinejoin=\"round\"\n                  initial={{ opacity: 0 }}\n                  animate={{ opacity: 1 }}\n                  exit={{ opacity: 0, transition: { duration: 0.1 } }}\n                >\n                  <motion.path\n                    d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n                    initial={{ pathLength: reduce ? 1 : 0 }}\n                    animate={{ pathLength: 1 }}\n                    transition={{ duration: reduce ? 0 : 0.32, ease: ease.out, delay: 0.04 }}\n                  />\n                </motion.svg>\n              )}\n            </AnimatePresence>\n          </span>\n        </div>\n        <div className=\"flex min-w-0 basis-full items-center border-t border-line @min-[340px]:flex-none @min-[340px]:basis-auto @min-[340px]:border-t-0\">\n          <input\n            ref={expiryRef}\n            data-segment=\"expiry\"\n            name={name ? `${name}-expiry` : undefined}\n            value={formatExpiry(value.expiry)}\n            onChange={onExpiry}\n            onKeyDown={seams}\n            onFocus={() => setFocus(\"expiry\")}\n            onBlur={blur(\"expiry\")}\n            disabled={disabled}\n            placeholder=\"MM / YY\"\n            aria-label=\"Expiry date, month and year\"\n            aria-invalid={!!issues.expiry || undefined}\n            aria-describedby={describe(\"expiry\")}\n            inputMode=\"numeric\"\n            autoComplete=\"cc-exp\"\n            className={cn(segmentClass(\"expiry\"), \"flex-1 px-2.5 @min-[340px]:w-[76px] @min-[340px]:flex-none @min-[340px]:px-2\")}\n          />\n          <span aria-hidden className=\"h-5 w-px shrink-0 bg-line @min-[340px]:hidden\" />\n          <input\n            ref={cvcRef}\n            data-segment=\"cvc\"\n            name={name ? `${name}-cvc` : undefined}\n            value={value.cvc}\n            onChange={onCvc}\n            onKeyDown={seams}\n            onFocus={() => setFocus(\"cvc\")}\n            onBlur={blur(\"cvc\")}\n            disabled={disabled}\n            placeholder={brand === \"amex\" ? \"CVC · 4\" : \"CVC\"}\n            aria-label={`Security code, ${cvcLength(brand)} digits`}\n            aria-invalid={!!issues.cvc || undefined}\n            aria-describedby={describe(\"cvc\")}\n            inputMode=\"numeric\"\n            autoComplete=\"cc-csc\"\n            className={cn(segmentClass(\"cvc\"), \"flex-1 px-2.5 @min-[340px]:w-[64px] @min-[340px]:flex-none @min-[340px]:pl-1.5 @min-[340px]:pr-2.5\")}\n          />\n        </div>\n      </div>\n      {/* The first problem only, under the field, and it closes as soon as it's fixed. */}\n      <div\n        className={cn(\n          \"grid transition-[grid-template-rows,opacity] duration-200 ease-in-out-quart\",\n          message ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\",\n        )}\n      >\n        <p id={errorId} role=\"alert\" className=\"flex min-h-0 items-start gap-1.5 overflow-hidden text-[12px] leading-[1.45] text-danger\">\n          {message && <Alert size={13} className=\"mt-[2px] shrink-0\" />}\n          <span>{message}</span>\n        </p>\n      </div>\n    </div>\n  );\n}\n\n/* -------------------------------------------------------------------------------------------------\n * The brand slot: a small card that names the network, and turns over to show where the\n * security code is while that segment has focus.\n * -----------------------------------------------------------------------------------------------*/\n\nfunction BrandMark({ brand, back, reduce }: { brand: CardBrand; back: boolean; reduce: boolean }) {\n  // Amex prints its code on the front, so the card doesn't turn over for it.\n  const side = back && brand !== \"amex\" ? \"back\" : \"front\";\n  const key = side === \"back\" ? \"back\" : `front-${brand}-${back ? \"cvc\" : \"\"}`;\n  return (\n    <span aria-hidden className=\"relative grid h-[18px] w-[26px] shrink-0 place-items-center [perspective:240px]\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          key={key}\n          className=\"absolute inset-0 grid place-items-center\"\n          initial={reduce ? { opacity: 0 } : side === \"back\" || key.includes(\"cvc\") ? { rotateY: 90, opacity: 0.6 } : { opacity: 0, scale: 0.7, filter: \"blur(2px)\" }}\n          animate={{ rotateY: 0, opacity: 1, scale: 1, filter: \"blur(0px)\" }}\n          exit={reduce ? { opacity: 0, transition: { duration: 0.1 } } : { rotateY: -90, opacity: 0.6, transition: { duration: 0.12 } }}\n          transition={reduce ? { duration: 0.12 } : spring.pop}\n        >\n          {side === \"back\" ? <CardBack /> : brand === \"unknown\" ? <CreditCard size={18} className=\"text-fg-3\" /> : <CardFront brand={brand} cvc={back} />}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n\nfunction CardFront({ brand, cvc }: { brand: Exclude<CardBrand, \"unknown\">; cvc: boolean }) {\n  return (\n    <span className=\"relative grid h-[18px] w-[26px] place-items-center rounded-[4px] border border-line-2 bg-hover\">\n      <span className=\"font-mono text-[7px] font-semibold leading-none tracking-[0.02em] text-fg-2\">{brandTag[brand]}</span>\n      {cvc && <span className=\"absolute right-[2px] top-[2px] h-[4px] w-[7px] rounded-[1px] bg-fg\" />}\n    </span>\n  );\n}\n\nfunction CardBack() {\n  return (\n    <span className=\"relative block h-[18px] w-[26px] overflow-hidden rounded-[4px] border border-line-2 bg-hover\">\n      <span className=\"absolute inset-x-0 top-[3px] h-[3px] bg-fg-4\" />\n      <span className=\"absolute left-[3px] top-[9px] h-[4px] w-[12px] rounded-[1px] bg-line-2\" />\n      <span className=\"absolute left-[16px] top-[8.5px] h-[5px] w-[7px] rounded-[1px] bg-fg\" />\n    </span>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/card-field"
}
