{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "masked-input",
  "type": "registry:ui",
  "title": "Masked Input",
  "description": "Formats cards, phones, dates and money as you type, caret held in place.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@base-ui/react",
    "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"
  ],
  "files": [
    {
      "path": "components/ui/masked-input.tsx",
      "content": "\"use client\";\nimport { Field } from \"@base-ui/react/field\";\nimport { Input } from \"@base-ui/react/input\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\n\n/* ───────────────────────── Pattern engine ─────────────────────────\n   A pattern is a string where # is a digit, A a letter, * either, and\n   anything else a literal the mask types for you: \"(###) ###-####\". */\n\nconst SLOT = /[#A*]/;\nconst fits = (ch: string, token: string) =>\n  token === \"#\" ? /\\d/.test(ch) : token === \"A\" ? /\\p{L}/u.test(ch) : token === \"*\" ? /[\\p{L}\\d]/u.test(ch) : false;\nconst slotCount = (pattern: string) => [...pattern].filter((c) => SLOT.test(c)).length;\n\n/** Pull the characters the pattern accepts out of whatever is in the field, in order. */\nexport function unmask(text: string, pattern: string) {\n  const slots = [...pattern].filter((c) => SLOT.test(c));\n  let out = \"\";\n  for (const ch of text) {\n    if (out.length >= slots.length) break;\n    if (fits(ch, slots[out.length])) out += ch;\n  }\n  return out;\n}\n\n/**\n * Lay raw characters into the pattern. Literals appear only once a character\n * follows them, unless `trailing` is set, which types the next separator for\n * you as soon as a group is full (\"04\" becomes \"04/\" while typing forward).\n */\nexport function applyMask(raw: string, pattern: string, trailing = false) {\n  let out = \"\";\n  let i = 0;\n  for (const token of pattern) {\n    if (SLOT.test(token)) {\n      if (i >= raw.length) break;\n      out += raw[i++];\n    } else {\n      if (i >= raw.length && !trailing) break;\n      out += token;\n    }\n  }\n  return out;\n}\n\n/** Where the caret belongs once `count` raw characters sit before it. */\nfunction caretAfter(formatted: string, pattern: string, count: number) {\n  if (count === 0) {\n    let p = 0;\n    while (p < formatted.length && !SLOT.test(pattern[p])) p++;\n    return p;\n  }\n  let seen = 0;\n  for (let p = 0; p < formatted.length; p++) {\n    if (SLOT.test(pattern[p])) seen++;\n    if (seen === count) {\n      // Hop over separators that follow, so the next keystroke lands in a slot.\n      let q = p + 1;\n      while (q < formatted.length && !SLOT.test(pattern[q])) q++;\n      return q;\n    }\n  }\n  return formatted.length;\n}\n\n/* ───────────────────────── Presets ───────────────────────── */\n\nexport type CardBrand = \"visa\" | \"mastercard\" | \"amex\" | \"discover\" | null;\nconst brandNames: Record<Exclude<CardBrand, null>, string> = { visa: \"Visa\", mastercard: \"Mastercard\", amex: \"Amex\", discover: \"Discover\" };\n\nexport function cardBrand(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 null;\n}\n\nexport function 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) {\n      d *= 2;\n      if (d > 9) d -= 9;\n    }\n    sum += d;\n  }\n  return digits.length > 0 && sum % 10 === 0;\n}\n\ntype Preset = {\n  pattern: (raw: string) => string;\n  ghost: (raw: string) => string;\n  inputMode: React.HTMLAttributes<HTMLInputElement>[\"inputMode\"];\n  autoComplete?: string;\n  /** Fix up raw input before it's laid out. */\n  refine?: (raw: string) => string;\n  check?: (raw: string) => boolean;\n  invalidMessage?: (raw: string, locale: string) => string;\n};\n\nconst presets: Record<\"card\" | \"phone\" | \"date\", Preset> = {\n  card: {\n    pattern: (raw) => (cardBrand(raw) === \"amex\" ? \"#### ###### #####\" : \"#### #### #### ####\"),\n    ghost: (raw) => (cardBrand(raw) === \"amex\" ? \"0000 000000 00000\" : \"0000 0000 0000 0000\"),\n    inputMode: \"numeric\",\n    autoComplete: \"cc-number\",\n    check: luhn,\n    invalidMessage: () => \"Check the card number for a typo\",\n  },\n  phone: {\n    pattern: () => \"(###) ###-####\",\n    ghost: () => \"(000) 000-0000\",\n    inputMode: \"tel\",\n    autoComplete: \"tel-national\",\n  },\n  date: {\n    pattern: () => \"##/##/####\",\n    ghost: () => \"MM/DD/YYYY\",\n    inputMode: \"numeric\",\n    // A 4 in the month can only mean April; a 5 in the day, the 5th.\n    refine: (raw) => {\n      let r = raw;\n      if (/^[2-9]/.test(r)) r = `0${r}`;\n      if (/^\\d{2}[4-9]/.test(r)) r = `${r.slice(0, 2)}0${r.slice(2)}`;\n      return r.slice(0, 8);\n    },\n    check: (raw) => parseDate(raw) !== null,\n    // Say exactly what's wrong: \"April has 30 days\" beats \"invalid date\".\n    invalidMessage: (raw, locale) => {\n      const m = Number(raw.slice(0, 2)), d = Number(raw.slice(2, 4)), y = Number(raw.slice(4));\n      if (m < 1 || m > 12) return \"Months go from 01 to 12\";\n      if (y <= 1900) return \"Check the year\";\n      const days = new Date(Date.UTC(y, m, 0)).getUTCDate();\n      const month = new Intl.DateTimeFormat(locale, { month: \"long\", timeZone: \"UTC\" }).format(new Date(Date.UTC(y, m - 1, 1)));\n      return d < 1 ? \"Days start at 01\" : `${month} ${y} has ${days} days`;\n    },\n  },\n};\n\nfunction parseDate(raw: string) {\n  if (raw.length !== 8) return null;\n  const m = Number(raw.slice(0, 2)), d = Number(raw.slice(2, 4)), y = Number(raw.slice(4));\n  const date = new Date(Date.UTC(y, m - 1, d));\n  return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d && y > 1900 ? date : null;\n}\n\n/* ───────────────────────── Currency ───────────────────────── */\n\nfunction currencyParts(locale: string, currency: string) {\n  const parts = new Intl.NumberFormat(locale, { style: \"currency\", currency }).formatToParts(1234.5);\n  const symbol = parts.find((p) => p.type === \"currency\")?.value ?? currency;\n  const decimal = parts.find((p) => p.type === \"decimal\")?.value ?? \".\";\n  const prefix = parts.findIndex((p) => p.type === \"currency\") < parts.findIndex((p) => p.type === \"integer\");\n  const fraction = new Intl.NumberFormat(locale, { style: \"currency\", currency }).resolvedOptions().maximumFractionDigits ?? 2;\n  return { symbol, decimal, prefix, fraction, group: new Intl.NumberFormat(locale, { maximumFractionDigits: 0, useGrouping: true }) };\n}\n\n/** Raw is a plain decimal string (\"1250.5\"); returns what the field shows. */\nfunction formatCurrency(raw: string, c: ReturnType<typeof currencyParts>) {\n  if (!raw) return \"\";\n  const [int, frac] = raw.split(\".\");\n  const grouped = int === \"\" ? \"0\" : c.group.format(BigInt(int));\n  return frac === undefined ? grouped : `${grouped}${c.decimal}${frac}`;\n}\n\n/** Reads digits and one decimal separator from the field, in order. */\nfunction parseCurrency(text: string, c: ReturnType<typeof currencyParts>) {\n  let int = \"\", frac: string | undefined;\n  for (const ch of text) {\n    if (/\\d/.test(ch)) {\n      if (frac === undefined) {\n        if (int.length < 12) int += ch;\n      } else if (frac.length < c.fraction) frac += ch;\n    } else if ((ch === c.decimal || ch === \".\") && frac === undefined && c.fraction > 0) frac = \"\";\n  }\n  int = int.replace(/^0+(?=\\d)/, \"\");\n  return frac === undefined ? int : `${int || \"0\"}.${frac}`;\n}\n\n/* ───────────────────────── Component ───────────────────────── */\n\nexport type MaskKind = \"card\" | \"phone\" | \"date\" | \"currency\";\n\nexport type MaskDetails = {\n  formatted: string;\n  /** Every slot is filled (for currency: there is an amount). */\n  complete: boolean;\n  /** Complete and passes the preset's check: Luhn for cards, a real date for dates. */\n  valid: boolean;\n  brand?: CardBrand;\n  /** For dates, the ISO form (YYYY-MM-DD) once valid. */\n  iso?: string;\n};\n\nexport type MaskedInputProps = Omit<React.ComponentProps<\"input\">, \"value\" | \"defaultValue\" | \"onChange\" | \"size\" | \"type\" | \"children\"> & {\n  /** A preset, or your own pattern: # digit, A letter, * either, anything else is typed for you. */\n  mask: MaskKind | (string & {});\n  /** The raw value: digits only for card, phone and date; a plain decimal for currency. */\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (raw: string, details: MaskDetails) => void;\n  label?: React.ReactNode;\n  description?: React.ReactNode;\n  /** An error from outside. Built-in checks (card number, date) show their own message on blur. */\n  error?: React.ReactNode;\n  size?: \"sm\" | \"md\" | \"lg\";\n  /** ISO 4217 code for the currency preset. */\n  currency?: string;\n  /** Locale for currency grouping and symbols. Fixed by default so server and client agree. */\n  locale?: string;\n  /** Ghost text for a custom pattern; defaults to the pattern with slots shown as 0. */\n  ghost?: string;\n};\n\nconst sizes = {\n  sm: { box: \"h-7 rounded-md\", pad: \"px-2\", text: \"text-base sm:text-[12.5px]\" },\n  md: { box: \"h-8 rounded-lg\", pad: \"px-2.5\", text: \"text-base sm:text-[13px]\" },\n  lg: { box: \"h-9 rounded-lg\", pad: \"px-3\", text: \"text-base sm:text-[13px]\" },\n};\n\nexport function MaskedInput({\n  mask,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  label,\n  description,\n  error: errorProp,\n  size = \"md\",\n  currency = \"USD\",\n  locale = \"en-US\",\n  ghost: ghostProp,\n  className,\n  disabled,\n  readOnly,\n  onBlur,\n  onFocus,\n  onKeyDown,\n  ref,\n  ...rest\n}: MaskedInputProps) {\n  const reduce = useReducedMotion();\n  const isCurrency = mask === \"currency\";\n  const preset: Preset | null = mask === \"card\" || mask === \"phone\" || mask === \"date\" ? presets[mask as keyof typeof presets] : null;\n  const money = useMemo(() => (isCurrency ? currencyParts(locale, currency) : null), [isCurrency, locale, currency]);\n\n  const [inner, setInner] = useState(defaultValue);\n  const raw = valueProp ?? inner;\n  // Trailing separators are shown only right after typing forward; stored so a re-render keeps them.\n  const [trailing, setTrailing] = useState(false);\n  const [touched, setTouched] = useState(false);\n  const [focused, setFocused] = useState(false);\n  const inputRef = useRef<HTMLInputElement | null>(null);\n  const pendingCaret = useRef<number | null>(null);\n\n  const pattern = preset ? preset.pattern(raw) : isCurrency ? \"\" : mask;\n  const formatted = money ? formatCurrency(raw, money) : applyMask(raw, pattern, trailing);\n  const slots = pattern ? slotCount(pattern) : 0;\n  const complete = money ? raw !== \"\" : raw.length === slots;\n  const valid = complete && (preset?.check ? preset.check(raw) : true);\n  const brand = mask === \"card\" ? cardBrand(raw) : undefined;\n  const date = mask === \"date\" && valid ? parseDate(raw) : null;\n\n  // The ghost is whatever of the template hasn't been typed yet, drawn behind the text.\n  const ghostFull = preset ? preset.ghost(raw) : ghostProp ?? pattern.replace(/[#*]/g, \"0\");\n  const ghostRest = money\n    ? raw === \"\"\n      ? \"\"\n      : raw.includes(\".\")\n        ? \"0\".repeat(money.fraction - (raw.split(\".\")[1]?.length ?? 0))\n        : money.fraction > 0\n          ? `${money.decimal}${\"0\".repeat(money.fraction)}`\n          : \"\"\n    : ghostFull.slice(formatted.length);\n\n  const builtInError = touched && !focused && complete && !valid ? preset?.invalidMessage?.(raw, locale) : undefined;\n  const error = errorProp ?? builtInError;\n\n  const commit = (nextRaw: string, caret: number | null, forward: boolean) => {\n    pendingCaret.current = caret;\n    setTrailing(forward);\n    if (valueProp === undefined) setInner(nextRaw);\n    if (nextRaw !== raw || forward !== trailing) {\n      const nextPattern = preset ? preset.pattern(nextRaw) : pattern;\n      const nextFormatted = money ? formatCurrency(nextRaw, money) : applyMask(nextRaw, nextPattern, forward);\n      const nextComplete = money ? nextRaw !== \"\" : nextRaw.length === slotCount(nextPattern);\n      const nextValid = nextComplete && (preset?.check ? preset.check(nextRaw) : true);\n      const d = mask === \"date\" && nextValid ? parseDate(nextRaw) : null;\n      onValueChange?.(nextRaw, {\n        formatted: nextFormatted,\n        complete: nextComplete,\n        valid: nextValid,\n        brand: mask === \"card\" ? cardBrand(nextRaw) : undefined,\n        iso: d ? d.toISOString().slice(0, 10) : undefined,\n      });\n    }\n  };\n\n  // Re-derive raw + caret from whatever the browser just put in the field.\n  const handle = (text: string, caretIn: number, forward: boolean) => {\n    if (money) {\n      const nextRaw = parseCurrency(text, money);\n      const countBefore = (s: string) => [...s].filter((ch) => /\\d/.test(ch) || ch === money.decimal || ch === \".\").length;\n      const want = countBefore(text.slice(0, caretIn));\n      const out = formatCurrency(nextRaw, money);\n      let p = 0, seen = 0;\n      while (p < out.length && seen < want) {\n        if (/\\d/.test(out[p]) || out[p] === money.decimal) seen++;\n        p++;\n      }\n      commit(nextRaw, p, forward);\n      return;\n    }\n    const before = unmask(text.slice(0, caretIn), pattern).length;\n    let nextRaw = unmask(text, preset ? preset.pattern(unmask(text, \"*\".repeat(40))) : pattern);\n    let shift = 0;\n    if (preset?.refine) {\n      const refined = preset.refine(nextRaw);\n      shift = refined.length - nextRaw.length;\n      nextRaw = refined;\n    }\n    const nextPattern = preset ? preset.pattern(nextRaw) : pattern;\n    const out = applyMask(nextRaw, nextPattern, forward);\n    commit(nextRaw, caretAfter(out, nextPattern, Math.min(nextRaw.length, before + Math.max(0, shift))), forward);\n  };\n\n  useLayoutEffect(() => {\n    const input = inputRef.current;\n    const caret = pendingCaret.current;\n    if (!input || caret === null || document.activeElement !== input) return;\n    pendingCaret.current = null;\n    input.setSelectionRange(caret, caret);\n  });\n\n  const s = sizes[size];\n  const adornStart = money?.prefix ? money.symbol : null;\n  const adornEnd = money && !money.prefix ? money.symbol : null;\n  const trailingHint =\n    mask === \"card\" && brand ? brandNames[brand] : date ? new Intl.DateTimeFormat(locale, { weekday: \"short\", timeZone: \"UTC\" }).format(date) : null;\n\n  return (\n    <Field.Root invalid={Boolean(error) || undefined} disabled={disabled} data-size={size} className={cn(\"group/field flex w-full min-w-0 flex-col gap-1.5\", className)}>\n      {label && <Field.Label className=\"w-fit text-[12.5px] font-medium leading-4 text-fg group-data-[disabled]/field:text-fg-3\">{label}</Field.Label>}\n      <div\n        data-slot=\"control\"\n        data-complete={complete || undefined}\n        className={cn(\n          \"relative flex items-center border border-line-2 bg-raised\",\n          \"transition-[border-color,box-shadow] duration-150 ease-out\",\n          \"hover:border-fg-4 focus-within:border-fg-3 focus-within:ring-3 focus-within:ring-fg/8 focus-within:hover:border-fg-3\",\n          \"group-data-[invalid]/field:border-danger/60 group-data-[invalid]/field:focus-within:border-danger/80 group-data-[invalid]/field:focus-within:ring-danger/15\",\n          \"group-data-[disabled]/field:pointer-events-none group-data-[disabled]/field:opacity-50\",\n          readOnly && \"bg-hover\",\n          s.box,\n        )}\n      >\n        {adornStart && <span aria-hidden className={cn(\"shrink-0 select-none pl-2.5 text-fg-3 tabular\", s.text)}>{adornStart}</span>}\n        <div className=\"relative flex h-full min-w-0 flex-1 items-center\">\n          {/* The ghost sits exactly under the text: the typed part invisible, the rest faint. */}\n          <span\n            aria-hidden\n            className={cn(\n              \"pointer-events-none absolute inset-0 flex items-center overflow-hidden whitespace-pre tabular\",\n              adornStart ? \"pl-1\" : s.pad,\n              s.text,\n            )}\n          >\n            <span className=\"invisible\">{formatted}</span>\n            <span className=\"text-fg-4\">{ghostRest}</span>\n          </span>\n          <Input\n            {...rest}\n            ref={(node: HTMLInputElement | null) => {\n              inputRef.current = node;\n              if (typeof ref === \"function\") ref(node);\n              else if (ref) ref.current = node;\n            }}\n            type=\"text\"\n            value={formatted}\n            disabled={disabled}\n            readOnly={readOnly}\n            inputMode={money ? \"decimal\" : preset?.inputMode ?? rest.inputMode}\n            autoComplete={rest.autoComplete ?? preset?.autoComplete ?? (money ? \"transaction-amount\" : \"off\")}\n            spellCheck={false}\n            onChange={(e) => {\n              const ev = e.nativeEvent as InputEvent;\n              const forward = !ev.inputType || ev.inputType.startsWith(\"insert\");\n              handle(e.target.value, e.target.selectionStart ?? e.target.value.length, forward);\n            }}\n            onKeyDown={(e) => {\n              onKeyDown?.(e);\n              if (e.defaultPrevented || money) return;\n              const el = e.currentTarget;\n              const start = el.selectionStart ?? 0;\n              if (start !== el.selectionEnd) return;\n              // Deleting across a separator deletes the character on its far side,\n              // instead of the separator popping straight back.\n              if (e.key === \"Backspace\" && start > 0 && !SLOT.test(pattern[start - 1] ?? \"#\")) {\n                let j = start - 1;\n                while (j >= 0 && !SLOT.test(pattern[j])) j--;\n                e.preventDefault();\n                if (j < 0) return;\n                handle(formatted.slice(0, j) + formatted.slice(j + 1), j, false);\n              } else if (e.key === \"Delete\" && start < formatted.length && !SLOT.test(pattern[start] ?? \"#\")) {\n                let j = start;\n                while (j < formatted.length && !SLOT.test(pattern[j])) j++;\n                e.preventDefault();\n                if (j >= formatted.length) return;\n                handle(formatted.slice(0, j) + formatted.slice(j + 1), start, false);\n              }\n            }}\n            onFocus={(e) => {\n              setFocused(true);\n              onFocus?.(e);\n            }}\n            onBlur={(e) => {\n              setFocused(false);\n              setTouched(true);\n              // Leaving the field drops a dangling separator (\"04/\" becomes \"04\").\n              if (trailing) setTrailing(false);\n              onBlur?.(e);\n            }}\n            className={cn(\n              \"relative h-full w-full min-w-0 bg-transparent text-fg tabular outline-none placeholder:text-fg-4\",\n              adornStart ? \"pl-1\" : s.pad,\n              adornEnd || trailingHint || valid ? \"pr-1\" : s.pad.replace(\"px\", \"pr\"),\n              s.text,\n              \"disabled:cursor-not-allowed\",\n            )}\n          />\n        </div>\n        {adornEnd && <span aria-hidden className={cn(\"shrink-0 select-none pr-2.5 text-fg-3\", s.text)}>{adornEnd}</span>}\n\n        {(trailingHint || (valid && !money)) && (\n          <span aria-hidden className=\"flex shrink-0 items-center gap-1.5 pr-2.5\">\n            <AnimatePresence initial={false} mode=\"popLayout\">\n              {trailingHint && (\n                <motion.span\n                  key={trailingHint}\n                  layout={reduce ? false : \"position\"}\n                  initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4, filter: \"blur(2px)\" }}\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={reduce ? { opacity: 0 } : { opacity: 0, y: -4, filter: \"blur(2px)\", transition: { duration: 0.12 } }}\n                  transition={{ duration: reduce ? 0.12 : 0.2, ease: ease.out }}\n                  className=\"text-[11.5px] font-medium text-fg-3\"\n                >\n                  {trailingHint}\n                </motion.span>\n              )}\n            </AnimatePresence>\n            {/* initial={false}: a value that's already valid on load shows its tick without drawing it. */}\n            <AnimatePresence initial={false}>{valid && !money && <Tick key=\"tick\" reduce={!!reduce} />}</AnimatePresence>\n          </span>\n        )}\n      </div>\n\n      <MessageSlot description={description} error={error} />\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {valid && mask === \"card\" && brand ? `${brandNames[brand]} card number complete` : date ? new Intl.DateTimeFormat(locale, { dateStyle: \"full\", timeZone: \"UTC\" }).format(date) : \"\"}\n      </span>\n    </Field.Root>\n  );\n}\n\nfunction Tick({ reduce }: { reduce: boolean }) {\n  return (\n    <motion.svg\n      width={14}\n      height={14}\n      viewBox=\"0 0 16 16\"\n      fill=\"none\"\n      stroke=\"currentColor\"\n      strokeWidth={1.8}\n      strokeLinecap=\"round\"\n      strokeLinejoin=\"round\"\n      className=\"text-success\"\n      initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.6 }}\n      animate={{ opacity: 1, scale: 1 }}\n      exit={{ opacity: 0, transition: { duration: 0.1 } }}\n      transition={reduce ? { duration: 0.12 } : spring.pop}\n    >\n      <motion.path\n        d=\"M3.5 8.5 6.5 11.5 12.5 4.5\"\n        initial={reduce ? false : { pathLength: 0 }}\n        animate={{ pathLength: 1 }}\n        transition={{ duration: 0.3, ease: ease.out, delay: 0.05 }}\n      />\n    </motion.svg>\n  );\n}\n\nfunction MessageSlot({ description, error }: { description?: React.ReactNode; error?: React.ReactNode }) {\n  if (!description && !error) return null;\n  return (\n    <div className=\"grid text-[12px] leading-4\">\n      {description && (\n        <Field.Description className=\"col-start-1 row-start-1 text-fg-3 transition-opacity duration-150 group-data-[invalid]/field:invisible group-data-[invalid]/field:opacity-0\">\n          {description}\n        </Field.Description>\n      )}\n      <Field.Error\n        match={error ? true : undefined}\n        className=\"col-start-1 row-start-1 flex items-start gap-1.5 text-danger transition-[opacity,translate] duration-200 ease-out-expo data-[starting-style]:-translate-y-1 data-[starting-style]:opacity-0 data-[ending-style]:opacity-0 data-[ending-style]:duration-100 motion-reduce:translate-y-0\"\n      >\n        {error ? (\n          <>\n            <Alert size={14} className=\"mt-px shrink-0\" />\n            <span>{error}</span>\n          </>\n        ) : undefined}\n      </Field.Error>\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/masked-input"
}
