{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-input","type":"registry:ui","title":"OTP Input","description":"The verification code field done right – a gliding focus ring, paste and SMS autofill, a calm shake on errors and a morph into Verified","dependencies":[],"files":[{"path":"registry/otp-input/otp-input.tsx","type":"registry:ui","content":"\"use client\";\n\nimport { useEffect, useId, useLayoutEffect, useRef, useState, type ClipboardEvent, type CSSProperties, type KeyboardEvent, type ReactNode } from \"react\";\n\n/*\n * OTP Input – the verification code field, in a single self-contained file.\n * One real input sits under the boxes, so typing, Backspace, paste and the SMS autofill of\n * iOS and Android all work natively; the boxes only draw what it holds.\n * The focus ring glides from box to box with a soft blinking caret, digits pop in, a pasted code\n * fills the boxes one after another. Verifying shimmers, a wrong code shakes and clears for retyping,\n * a right one folds the boxes into a single \"Verified\" pill. A resend link counts down below.\n * The styles are injected below, so no extra files are needed.\n */\n\nexport type OtpStatus = \"idle\" | \"verifying\" | \"error\" | \"success\";\n\nexport type OtpInputProps = {\n  /** Number of boxes */\n  length?: number;\n  /** A small dash between the boxes – `true` puts it in the middle, a number after that many boxes */\n  separator?: boolean | number;\n  /** Digits only (numeric keyboard on phones); `false` allows letters too, shown in capitals */\n  numeric?: boolean;\n  /** The code – makes it controlled */\n  value?: string;\n  /** Starting code when uncontrolled */\n  defaultValue?: string;\n  onChange?: (code: string) => void;\n  /** Called once the last box is filled – typed, pasted or autofilled */\n  onComplete?: (code: string) => void;\n  status?: OtpStatus;\n  /** Shown below the boxes while `status` is \"error\" */\n  errorMessage?: ReactNode;\n  /** Clears the boxes shortly after an error, ready for the next try */\n  clearOnError?: boolean;\n  /** Text in the pill after a successful check */\n  successLabel?: string;\n  /** Shows \"Resend code\" below the boxes once the countdown is over */\n  onResend?: () => void;\n  /** Seconds until the code can be resent */\n  resendIn?: number;\n  /** Center the boxes and the lines below them */\n  align?: \"start\" | \"center\";\n  disabled?: boolean;\n  autoFocus?: boolean;\n  /** Accessible name of the field */\n  label?: string;\n  className?: string;\n};\n\n/* ---------- Helpers ---------- */\n\nconst pad = (n: number) => String(n).padStart(2, \"0\");\n/** \"0:24\", \"1:05:00\", \"3d 1h\" */\nconst clock = (s: number) => {\n  if (s >= 86400) return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;\n  if (s >= 3600) return `${Math.floor(s / 3600)}:${pad(Math.floor((s % 3600) / 60))}:${pad(s % 60)}`;\n  return `${Math.floor(s / 60)}:${pad(s % 60)}`;\n};\n\n/** Longest code the boxes are drawn for – past that the cells get too small to read */\nconst MAX_LENGTH = 24;\n\n/** Full-width (１２３) and Arabic-Indic digits (٤٨٢) count as the digits they are */\nconst normalize = (text: string) =>\n  text\n    .normalize(\"NFKC\")\n    .replace(/[\\u0660-\\u0669]/g, (d) => String(d.charCodeAt(0) - 0x660))\n    .replace(/[\\u06f0-\\u06f9]/g, (d) => String(d.charCodeAt(0) - 0x6f0));\n\nconst reduced = () => typeof window !== \"undefined\" && window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n\nconst icons = {\n  check: \"M5.5 12.5l4 4L18.5 8\",\n  alert: \"M12 8v4.5M12 16h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0z\",\n};\n\nexport function OtpInput({\n  length: lengthProp = 6,\n  separator = false,\n  numeric = true,\n  value,\n  defaultValue = \"\",\n  onChange,\n  onComplete,\n  status = \"idle\",\n  errorMessage = \"That code didn't work. Try again.\",\n  clearOnError = true,\n  successLabel = \"Verified\",\n  onResend,\n  resendIn: resendInProp = 30,\n  align = \"start\",\n  disabled = false,\n  autoFocus = false,\n  label = \"Verification code\",\n  className,\n}: OtpInputProps) {\n  // A NaN or fractional length and a NaN countdown fall back instead of breaking the field\n  const length = Number.isFinite(lengthProp) ? Math.min(MAX_LENGTH, Math.max(1, Math.floor(lengthProp))) : 6;\n  const resendIn = Number.isFinite(resendInProp) && resendInProp > 0 ? Math.floor(resendInProp) : 0;\n\n  /** Keeps allowed characters only – spaces and dashes of a pasted \"482 913\" fall away */\n  const clean = (text: string) => {\n    let out = \"\";\n    for (const ch of normalize(String(text ?? \"\"))) {\n      const c = numeric ? (/[0-9]/.test(ch) ? ch : \"\") : /[a-z0-9]/i.test(ch) ? ch.toUpperCase() : \"\";\n      if (c && out.length < length) out += c;\n    }\n    return out;\n  };\n\n  const [inner, setInner] = useState(() => clean(defaultValue));\n  const code = clean(value ?? inner);\n  const [idx, setIdx] = useState(Math.min(code.length, length - 1));\n  const [focused, setFocused] = useState(false);\n  const [errorShown, setErrorShown] = useState(false);\n  const [clearing, setClearing] = useState(false); // the wrong code fades out\n  const [locked, setLocked] = useState(false); // no typing between the shake and the clearing\n  const [left, setLeft] = useState(resendIn);\n  const [announce, setAnnounce] = useState(\"\");\n\n  const input = useRef<HTMLInputElement>(null);\n  const slots = useRef<HTMLDivElement>(null);\n  const ring = useRef<HTMLSpanElement>(null);\n  const pill = useRef<HTMLSpanElement>(null);\n  const pillInner = useRef<HTMLSpanElement>(null);\n  const boxes = useRef<(HTMLSpanElement | null)[]>([]);\n  const timers = useRef<number[]>([]);\n  const id = useId();\n\n  // Every character remembers when it arrived, so it pops in once; pasted codes arrive one after another\n  const prevCode = useRef(code);\n  const stamps = useRef<number[]>([]);\n  const delays = useRef<number[]>([]);\n  const counter = useRef(0);\n  if (prevCode.current !== code) {\n    const before = prevCode.current;\n    const changed = [...code].map((c, i) => c !== before[i]);\n    const first = changed.indexOf(true);\n    const bulk = changed.filter(Boolean).length > 1;\n    changed.forEach((ch, i) => {\n      if (!ch) return;\n      stamps.current[i] = ++counter.current;\n      delays.current[i] = bulk ? (i - first) * 45 : 0;\n    });\n    prevCode.current = code;\n  }\n\n  // An outside change of the value (or a shorter code) keeps the active box in reach\n  const active = Math.min(idx, code.length, length - 1);\n\n  const sepAt = separator === true ? Math.floor(length / 2) : typeof separator === \"number\" ? separator : 0;\n  const busy = status === \"verifying\" || status === \"success\";\n  const readOnly = busy || locked || disabled;\n  const showError = status === \"error\" && errorShown;\n  // The ring shows where the next character goes: while focused, or mid-way through a code\n  const showRing = !busy && !disabled && (focused || (code.length > 0 && code.length < length));\n\n  const commit = (next: string, nextIdx: number, user = true) => {\n    if (value === undefined) setInner(next);\n    if (next !== code) onChange?.(next);\n    setIdx(Math.max(0, Math.min(nextIdx, next.length, length - 1)));\n    if (!user) return;\n    if (errorShown && next.length) setErrorShown(false);\n    if (next.length === length && next !== code) onComplete?.(next);\n  };\n\n  /* ---------- Native input ---------- */\n\n  const onInput = (el: HTMLInputElement) => {\n    if (readOnly) return;\n    const raw = el.value;\n    const caretRaw = el.selectionStart ?? raw.length;\n    const next = clean(raw);\n    // The caret counts the kept characters in front of it\n    const caret = clean(raw.slice(0, caretRaw)).length;\n    if (next === code) {\n      el.value = code;\n      syncSelection();\n      return;\n    }\n    commit(next, caret);\n  };\n\n  const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {\n    e.preventDefault();\n    if (readOnly) return;\n    const text = clean(e.clipboardData.getData(\"text\"));\n    if (!text) return;\n    // A whole code replaces everything; a part is written from the active box on\n    if (text.length >= length) return commit(text.slice(0, length), length - 1);\n    const next = (code.slice(0, active) + text + code.slice(active + text.length)).slice(0, length);\n    commit(next, active + text.length);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {\n    const last = Math.min(code.length, length - 1);\n    const moves: Record<string, number> = { ArrowLeft: active - 1, ArrowRight: active + 1, Home: 0, End: last, ArrowUp: 0, ArrowDown: last };\n    if (e.key in moves && !e.shiftKey && !e.metaKey) {\n      e.preventDefault();\n      setIdx(Math.max(0, Math.min(moves[e.key], last)));\n    }\n  };\n\n  /** A tap or click picks the box under the pointer – never beyond the first empty one */\n  const pick = (x: number) => {\n    let i = boxes.current.findIndex((b) => b && x < b.getBoundingClientRect().right + 4);\n    if (i < 0) i = length - 1;\n    setIdx(Math.min(i, code.length, length - 1));\n    requestAnimationFrame(syncSelection);\n  };\n\n  // The real input selects the active character, so typing replaces it and Backspace removes it\n  const syncSelection = () => {\n    const el = input.current;\n    if (!el || document.activeElement !== el) return;\n    const i = Math.min(idx, code.length, length - 1);\n    if (i < code.length) el.setSelectionRange(i, i + 1);\n    else el.setSelectionRange(i, i);\n  };\n  useLayoutEffect(syncSelection);\n\n  useEffect(() => {\n    if (autoFocus) input.current?.focus({ preventScroll: true });\n  }, [autoFocus]);\n\n  /* ---------- The gliding ring and the success pill ---------- */\n\n  useLayoutEffect(() => {\n    const r = ring.current;\n    const box = boxes.current[active];\n    if (r && box) {\n      const first = !r.dataset.ready;\n      if (first) r.style.transition = \"none\";\n      r.style.width = `${box.offsetWidth}px`;\n      r.style.height = `${box.offsetHeight}px`;\n      r.style.transform = `translate(${box.offsetLeft}px, ${box.offsetTop}px)`;\n      if (first) {\n        r.dataset.ready = \"1\";\n        void r.offsetWidth;\n        r.style.transition = \"\";\n      }\n    }\n    // The pill starts as wide as all the boxes and morphs down to its label\n    const p = pill.current;\n    const s = slots.current;\n    if (p && s && pillInner.current) {\n      const w = status === \"success\" ? pillInner.current.offsetWidth : s.offsetWidth;\n      p.style.width = `${w}px`;\n      // A label wider than the boxes slides back inside the field instead of hanging over its edge\n      const root = s.parentElement?.parentElement;\n      let shift = 0;\n      if (root && w > s.offsetWidth) {\n        const box = root.getBoundingClientRect();\n        const own = s.getBoundingClientRect();\n        const left = own.left + own.width / 2 - w / 2;\n        shift = Math.min(Math.max(left, box.left), box.right - w) - left;\n      }\n      p.style.marginLeft = shift ? `${shift}px` : \"\";\n    }\n  });\n\n  // Phones turning, containers resizing: ring and pill follow\n  useEffect(() => {\n    const s = slots.current;\n    if (!s) return;\n    const ro = new ResizeObserver(() => {\n      const r = ring.current;\n      const box = boxes.current[active];\n      if (r && box) {\n        r.style.width = `${box.offsetWidth}px`;\n        r.style.height = `${box.offsetHeight}px`;\n        r.style.transform = `translate(${box.offsetLeft}px, ${box.offsetTop}px)`;\n      }\n      if (pill.current && status !== \"success\") pill.current.style.width = `${s.offsetWidth}px`;\n    });\n    ro.observe(s);\n    return () => ro.disconnect();\n  }, [active, status]);\n\n  /* ---------- Status ---------- */\n\n  const prevStatus = useRef(status);\n  useEffect(() => {\n    const before = prevStatus.current;\n    prevStatus.current = status;\n    if (status === before) return;\n    if (status === \"verifying\") setAnnounce(\"Verifying code\");\n    if (status === \"success\") setAnnounce(successLabel);\n    if (status !== \"error\") {\n      setErrorShown(false);\n      return;\n    }\n    setErrorShown(true);\n    setAnnounce(typeof errorMessage === \"string\" ? errorMessage : \"That code didn't work\");\n    // A calm shake, then the wrong code fades out so the next try starts fresh\n    if (!reduced()) {\n      slots.current?.animate(\n        [{ transform: \"none\" }, { transform: \"translateX(-6px)\" }, { transform: \"translateX(5px)\" }, { transform: \"translateX(-3px)\" }, { transform: \"translateX(2px)\" }, { transform: \"none\" }],\n        { duration: 420, easing: \"ease-out\" },\n      );\n    }\n    if (!clearOnError) return;\n    setLocked(true);\n    timers.current.push(\n      window.setTimeout(() => setClearing(true), 700),\n      window.setTimeout(() => {\n        setClearing(false);\n        setLocked(false);\n        commit(\"\", 0, false);\n      }, 980),\n    );\n  }, [status]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  useEffect(() => () => timers.current.forEach(clearTimeout), []);\n\n  /* ---------- Resend countdown ---------- */\n\n  useEffect(() => {\n    if (left <= 0) {\n      if (onResend && resendIn > 0) setAnnounce(\"You can resend the code now\");\n      return;\n    }\n    const t = window.setTimeout(() => setLeft((s) => s - 1), 1000);\n    return () => clearTimeout(t);\n  }, [left]); // eslint-disable-line react-hooks/exhaustive-deps\n\n  const resend = () => {\n    onResend?.();\n    setLeft(resendIn);\n    setAnnounce(\"A new code is on its way\");\n  };\n\n  /* ---------- Render ---------- */\n\n  const cells: ReactNode[] = [];\n  for (let i = 0; i < length; i++) {\n    if (sepAt > 0 && sepAt < length && i === sepAt) cells.push(<span key=\"sep\" className=\"ui-otp__sep\" />);\n    const ch = code[i];\n    cells.push(\n      <span\n        key={i}\n        ref={(el) => {\n          boxes.current[i] = el;\n        }}\n        className=\"ui-otp__box\"\n        data-filled={ch ? \"\" : undefined}\n        style={{ \"--i\": i } as CSSProperties}\n      >\n        {ch && (\n          <span key={stamps.current[i]} className=\"ui-otp__char\" style={{ animationDelay: `${delays.current[i] ?? 0}ms` }}>\n            {ch}\n          </span>\n        )}\n      </span>,\n    );\n  }\n\n  return (\n    <div\n      className={[\"ui-otp\", className].filter(Boolean).join(\" \")}\n      data-status={status}\n      data-error={showError || undefined}\n      data-align={align}\n      data-disabled={disabled || undefined}\n    >\n      {/* React 19 hoists this into <head> once, no matter how many fields render */}\n      <style href=\"ui-otp-input\" precedence=\"default\">\n        {css}\n      </style>\n\n      <div className=\"ui-otp__field\">\n        <div\n          ref={slots}\n          className=\"ui-otp__slots\"\n          dir=\"ltr\"\n          data-clearing={clearing || undefined}\n          style={{ \"--n\": length, \"--sep-w\": sepAt > 0 && sepAt < length ? \"calc(var(--ui-otp-gap) + 10px)\" : \"0px\" } as CSSProperties}\n        >\n          {cells}\n          <span ref={ring} className=\"ui-otp__ring\" data-show={showRing || undefined} aria-hidden=\"true\">\n            <span className=\"ui-otp__caret\" data-show={(focused && !code[active] && !readOnly) || undefined} />\n          </span>\n          <span ref={pill} className=\"ui-otp__pill\" aria-hidden=\"true\">\n            <span ref={pillInner} className=\"ui-otp__pill-inner\">\n              <svg viewBox=\"0 0 24 24\" className=\"ui-otp__check\" aria-hidden=\"true\">\n                <path d={icons.check} pathLength={1} />\n              </svg>\n              <span className=\"ui-otp__pill-text\" title={successLabel}>\n                {successLabel}\n              </span>\n            </span>\n          </span>\n          <input\n            ref={input}\n            className=\"ui-otp__input\"\n            type=\"text\"\n            value={code}\n            inputMode={numeric ? \"numeric\" : \"text\"}\n            pattern={numeric ? \"[0-9]*\" : undefined}\n            autoComplete=\"one-time-code\"\n            autoCapitalize={numeric ? \"off\" : \"characters\"}\n            autoCorrect=\"off\"\n            spellCheck={false}\n            enterKeyHint=\"done\"\n            aria-label={`${label}, ${length} ${numeric ? (length === 1 ? \"digit\" : \"digits\") : length === 1 ? \"character\" : \"characters\"}`}\n            aria-invalid={showError || undefined}\n            aria-describedby={showError ? `${id}-msg` : undefined}\n            disabled={disabled}\n            readOnly={busy || locked}\n            onChange={(e) => onInput(e.currentTarget)}\n            onPaste={onPaste}\n            onKeyDown={onKeyDown}\n            onFocus={() => {\n              setFocused(true);\n              requestAnimationFrame(syncSelection);\n            }}\n            onBlur={() => setFocused(false)}\n            onPointerUp={(e) => pick(e.clientX)}\n            onSelect={(e) => {\n              // A native caret move (long press, mouse drag) snaps back to a single box\n              const el = e.currentTarget;\n              const want = active < code.length ? [active, active + 1] : [active, active];\n              if (el.selectionStart !== want[0] || el.selectionEnd !== want[1]) syncSelection();\n            }}\n          />\n        </div>\n      </div>\n\n      {/* The error message slides in below and folds away once retyping starts */}\n      <div className=\"ui-otp__msg\" data-show={showError || undefined}>\n        <div className=\"ui-otp__msg-inner\">\n          <p id={`${id}-msg`} className=\"ui-otp__msg-text\">\n            <svg viewBox=\"0 0 24 24\" className=\"ui-otp__alert\" aria-hidden=\"true\">\n              <path d={icons.alert} />\n            </svg>\n            <span>{errorMessage}</span>\n          </p>\n        </div>\n      </div>\n\n      {onResend && (\n        <div className=\"ui-otp__resend\" data-hidden={status === \"success\" || undefined}>\n          <span className=\"ui-otp__resend-q\">Didn't get a code?</span>{\" \"}\n          {left > 0 ? (\n            <span key=\"wait\" className=\"ui-otp__swap ui-otp__wait\">\n              Resend in{\"\\u00a0\"}\n              <span className=\"ui-otp__time\">{clock(left)}</span>\n            </span>\n          ) : (\n            <button key=\"go\" type=\"button\" className=\"ui-otp__swap ui-otp__resend-btn\" onClick={resend} disabled={disabled || busy}>\n              Resend code\n            </button>\n          )}\n        </div>\n      )}\n\n      <span className=\"ui-otp__live\" aria-live=\"polite\">\n        {announce}\n      </span>\n    </div>\n  );\n}\n\n/* ---------- Styles ---------- */\n\nconst css = /* css */ `\n/* Colors, size and motion as variables – override them on .ui-otp */\n.ui-otp {\n  --ui-otp-box: 48px;\n  --ui-otp-gap: 8px;\n  --ui-otp-bg: var(--aui-subtle, #fafafa);\n  --ui-otp-card: var(--aui-surface, #ffffff);\n  --ui-otp-line: var(--aui-border, #e4e4e7);\n  --ui-otp-line-strong: var(--aui-border-strong, #cfcfd4);\n  --ui-otp-ink: var(--aui-ink, #171717);\n  --ui-otp-text: var(--aui-ink, #404040);\n  --ui-otp-muted: var(--aui-muted, #8f8f8f);\n  --ui-otp-faint: #c4c4c8;\n  --ui-otp-halo: rgba(23, 23, 23, 0.07);\n  --ui-otp-wait: #f2f2f3;\n  --ui-otp-shine: rgba(255, 255, 255, 0.95);\n  --ui-otp-danger: var(--aui-red, #f04438);\n  --ui-otp-danger-line: color-mix(in srgb, var(--aui-red, #f04438) 60%, transparent);\n  --ui-otp-danger-halo: color-mix(in srgb, var(--aui-red, #f04438) 12%, transparent);\n  --ui-otp-success: var(--aui-success, #1e8a4f);\n  --ui-otp-accent: var(--aui-blue, #4fb0fe);\n  --ui-otp-focus: var(--aui-focus, rgba(0, 96, 226, 0.7));\n  --ui-otp-ease: var(--aui-ease, cubic-bezier(0.22, 1, 0.36, 1));\n  box-sizing: border-box;\n  container-type: inline-size;\n  display: grid;\n  justify-self: stretch; /* it sizes the boxes from its own width, so it must never shrink to fit them */\n  width: 100%;\n  font-family: var(--aui-font, \"Inter\", ui-sans-serif, system-ui, -apple-system, \"Segoe UI\", Roboto, sans-serif);\n  font-size: 13.5px;\n  line-height: 1.45;\n  color: var(--ui-otp-text);\n  -webkit-font-smoothing: antialiased;\n}\n.ui-otp *, .ui-otp *::before, .ui-otp *::after { box-sizing: border-box; }\n.ui-otp[data-align=\"center\"] { justify-items: center; text-align: center; }\n.ui-otp[data-disabled] { opacity: 0.55; }\n\n.ui-otp__field { display: flex; min-width: 0; }\n.ui-otp[data-align=\"center\"] .ui-otp__field { justify-content: center; }\n\n/* The boxes scale down to fit their container (phones at 320px), up to --ui-otp-box */\n.ui-otp__slots {\n  --size: min(var(--ui-otp-box), calc((100cqi - (var(--n) - 1) * var(--ui-otp-gap) - var(--sep-w)) / var(--n)));\n  position: relative;\n  display: flex;\n  align-items: center;\n  gap: var(--ui-otp-gap);\n}\n.ui-otp__box {\n  position: relative;\n  display: grid;\n  place-items: center;\n  flex: none;\n  width: var(--size);\n  height: calc(var(--size) * 1.18);\n  overflow: hidden;\n  border-radius: calc(var(--size) * 0.24);\n  background: var(--ui-otp-bg);\n  box-shadow: inset 0 0 0 1px var(--ui-otp-line);\n  font-size: calc(var(--size) * 0.46);\n  font-weight: 500;\n  font-variant-numeric: tabular-nums;\n  color: var(--ui-otp-ink);\n  transition: box-shadow 220ms ease, background-color 220ms ease, transform 380ms var(--ui-otp-ease), opacity 260ms ease;\n}\n.ui-otp__box[data-filled] { background: var(--ui-otp-card); box-shadow: inset 0 0 0 1px var(--ui-otp-line-strong); }\n.ui-otp__sep { flex: none; width: 10px; height: 2px; border-radius: 1px; background: var(--ui-otp-faint); transition: opacity 200ms ease; }\n\n/* Digits pop in – a small lift out of a blur, no bounce */\n.ui-otp__char { display: block; animation: ui-otp-pop 280ms var(--ui-otp-ease) both; }\n.ui-otp__slots[data-clearing] .ui-otp__char { animation: ui-otp-out 240ms ease both; animation-delay: calc((var(--n) - var(--i)) * 22ms) !important; }\n\n/* One ring glides from box to box; its caret blinks softly in an empty box */\n.ui-otp__ring {\n  position: absolute;\n  top: 0;\n  left: 0;\n  z-index: 1;\n  display: grid;\n  place-items: center;\n  border-radius: calc(var(--size) * 0.24);\n  box-shadow: inset 0 0 0 1.5px var(--ui-otp-ink), 0 0 0 4px var(--ui-otp-halo);\n  opacity: 0;\n  pointer-events: none;\n  transition: transform 260ms var(--ui-otp-ease), width 260ms var(--ui-otp-ease), opacity 180ms ease, box-shadow 220ms ease;\n}\n.ui-otp__ring[data-show] { opacity: 1; }\n.ui-otp__caret { width: 1.5px; height: 40%; border-radius: 1px; background: var(--ui-otp-ink); opacity: 0; }\n.ui-otp__caret[data-show] { animation: ui-otp-blink 1.1s ease-in-out infinite; }\n\n/* The real input lies over the boxes, invisible – it takes taps, keys, paste and SMS autofill */\n.ui-otp__input {\n  position: absolute;\n  inset: 0;\n  z-index: 2;\n  width: 100%;\n  height: 100%;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  outline: none;\n  background: transparent;\n  color: transparent;\n  -webkit-text-fill-color: transparent;\n  caret-color: transparent;\n  font-size: 16px; /* iOS doesn't zoom into inputs of 16px and up */\n  letter-spacing: -1em;\n  cursor: text;\n  appearance: none;\n}\n.ui-otp__input::selection { background: transparent; }\n.ui-otp__input:-webkit-autofill { -webkit-box-shadow: 0 0 0 100px transparent inset; transition: background-color 9999s; }\n.ui-otp__input[readonly], .ui-otp__input:disabled { cursor: default; }\n\n/* Verifying: a soft light passes over the boxes, one after another */\n.ui-otp__box::after {\n  content: \"\";\n  position: absolute;\n  inset: 0;\n  background: linear-gradient(100deg, transparent 20%, var(--ui-otp-shine) 50%, transparent 80%);\n  transform: translateX(-120%);\n  opacity: 0;\n  pointer-events: none;\n}\n.ui-otp[data-status=\"verifying\"] .ui-otp__box::after { opacity: 1; animation: ui-otp-shimmer 1.3s ease-in-out infinite; animation-delay: calc(var(--i) * 70ms); }\n.ui-otp[data-status=\"verifying\"] .ui-otp__box { background: var(--ui-otp-wait); box-shadow: inset 0 0 0 1px var(--ui-otp-line); color: var(--ui-otp-text); }\n\n/* Error: only the borders turn red */\n.ui-otp[data-error] .ui-otp__box { box-shadow: inset 0 0 0 1px var(--ui-otp-danger-line); }\n.ui-otp[data-error] .ui-otp__ring { box-shadow: inset 0 0 0 1.5px var(--ui-otp-danger), 0 0 0 4px var(--ui-otp-danger-halo); }\n.ui-otp[data-error] .ui-otp__caret { background: var(--ui-otp-danger); }\n\n/* Success: the boxes slide together and fade while one frame morphs into the \"Verified\" pill */\n.ui-otp__pill {\n  position: absolute;\n  top: 50%;\n  left: 50%;\n  z-index: 1;\n  display: grid;\n  place-items: center;\n  height: calc(var(--size) * 1.18);\n  overflow: hidden;\n  border-radius: calc(var(--size) * 0.24);\n  background: var(--ui-otp-card);\n  box-shadow: inset 0 0 0 1px var(--ui-otp-line-strong);\n  opacity: 0;\n  transform: translate(-50%, -50%);\n  pointer-events: none;\n  transition: width 300ms var(--ui-otp-ease), height 300ms var(--ui-otp-ease), border-radius 300ms var(--ui-otp-ease), opacity 160ms ease 140ms;\n}\n.ui-otp__pill-inner {\n  display: inline-flex;\n  max-width: 100cqi; /* a long label shortens instead of leaving the field */\n  align-items: center;\n  gap: 6px;\n  padding: 0 15px 0 12px;\n  font-size: 14px;\n  font-weight: 500;\n  line-height: 20px;\n  white-space: nowrap;\n  color: var(--ui-otp-ink);\n  opacity: 0;\n  transition: opacity 160ms ease;\n}\n.ui-otp__pill-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; }\n.ui-otp__check { flex: none; width: 17px; height: 17px; fill: none; stroke: var(--ui-otp-success); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; }\n.ui-otp__check path { stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset 120ms ease; }\n.ui-otp[data-status=\"success\"] .ui-otp__box {\n  transform: translateX(calc(((var(--n) - 1) / 2 - var(--i)) * (var(--size) + var(--ui-otp-gap)))) scale(0.7);\n  opacity: 0;\n  transition: transform 340ms var(--ui-otp-ease), opacity 220ms ease 120ms;\n}\n.ui-otp[data-status=\"success\"] .ui-otp__sep { opacity: 0; }\n/* The pill settles at a compact height, not the height of the boxes */\n.ui-otp[data-status=\"success\"] .ui-otp__pill {\n  height: 36px;\n  border-radius: 999px;\n  opacity: 1;\n  transition: width 460ms var(--ui-otp-ease) 220ms, height 460ms var(--ui-otp-ease) 220ms, border-radius 460ms var(--ui-otp-ease) 220ms, opacity 160ms ease 120ms;\n}\n.ui-otp[data-status=\"success\"] .ui-otp__pill-inner { opacity: 1; transition: opacity 240ms ease 460ms; }\n.ui-otp[data-status=\"success\"] .ui-otp__check path { stroke-dashoffset: 0; transition: stroke-dashoffset 380ms var(--ui-otp-ease) 560ms; }\n\n/* The error message grows in below, then folds away */\n.ui-otp__msg { display: grid; grid-template-rows: 0fr; width: 100%; transition: grid-template-rows 300ms var(--ui-otp-ease); }\n.ui-otp__msg[data-show] { grid-template-rows: 1fr; }\n.ui-otp__msg-inner { min-height: 0; overflow: hidden; }\n.ui-otp__msg-text {\n  margin: 0;\n  overflow-wrap: anywhere;\n  padding-top: 10px;\n  font-size: 13px;\n  text-wrap: balance;\n  color: var(--ui-otp-text);\n  opacity: 0;\n  transform: translateY(-4px);\n  transition: opacity 200ms ease, transform 300ms var(--ui-otp-ease);\n}\n.ui-otp__msg[data-show] .ui-otp__msg-text { opacity: 1; transform: none; transition-delay: 60ms; }\n/* The icon sits inline, so a wrapped message stays centered with it */\n.ui-otp__alert { display: inline-block; width: 15px; height: 15px; margin-right: 6px; vertical-align: -3px; fill: none; stroke: var(--ui-otp-danger); stroke-width: 2; stroke-linecap: round; }\n\n/* Resend: a countdown that turns into a button */\n.ui-otp__resend { margin-top: 14px; font-size: 13px; font-variant-numeric: tabular-nums; color: var(--ui-otp-muted); transition: opacity 220ms ease; }\n.ui-otp__resend[data-hidden] { opacity: 0; visibility: hidden; transition: opacity 220ms ease, visibility 0s 220ms; }\n.ui-otp__swap { display: inline-block; animation: ui-otp-in 260ms var(--ui-otp-ease) both; }\n.ui-otp__wait { color: var(--ui-otp-text); }\n.ui-otp__time { display: inline-block; min-width: 3.4ch; text-align: left; }\n.ui-otp__resend-btn {\n  position: relative;\n  margin: 0;\n  padding: 0;\n  border: 0;\n  background: none;\n  font: inherit;\n  font-weight: 500;\n  border-radius: 4px;\n  color: var(--ui-otp-accent);\n  cursor: pointer;\n}\n.ui-otp__resend-btn::before { content: \"\"; position: absolute; inset: -6px -8px; border-radius: 8px; }\n.ui-otp__resend-btn:hover { text-decoration: underline; text-underline-offset: 3px; }\n.ui-otp__resend-btn:focus-visible { outline: 2px solid var(--ui-otp-focus); outline-offset: 2px; }\n.ui-otp__resend-btn:disabled { color: var(--ui-otp-muted); cursor: default; text-decoration: none; }\n.ui-otp__live { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n\n@keyframes ui-otp-pop { from { opacity: 0; transform: translateY(4px) scale(0.7); filter: blur(3px); } to { opacity: 1; transform: none; filter: none; } }\n@keyframes ui-otp-out { to { opacity: 0; transform: translateY(-4px) scale(0.8); filter: blur(2px); } }\n@keyframes ui-otp-blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }\n@keyframes ui-otp-shimmer { 0% { transform: translateX(-120%); } 55%, 100% { transform: translateX(120%); } }\n@keyframes ui-otp-in { from { opacity: 0; transform: translateY(3px); filter: blur(2px); } to { opacity: 1; transform: none; filter: none; } }\n\n/* Narrow containers (phones): tighter gaps, the question before \"Resend\" drops first */\n@container (max-width: 360px) {\n  .ui-otp__slots { --ui-otp-gap: 6px; }\n  .ui-otp__sep { width: 8px; }\n}\n@container (max-width: 250px) {\n  .ui-otp__resend-q { display: none; }\n}\n/* Touch screens: a bigger target for Resend */\n@media (hover: none) and (pointer: coarse) {\n  .ui-otp__resend-btn::before { inset: -11px -12px; }\n}\n@media (prefers-reduced-motion: reduce) {\n  .ui-otp *, .ui-otp *::before, .ui-otp *::after { animation: none !important; transition: none !important; }\n  .ui-otp__caret[data-show] { opacity: 1; }\n}\n\n/* Dark mode: follows a .dark class or data-theme=\"dark\" on any ancestor, e.g. <html> */\n:where(.dark, [data-theme=\"dark\"]) .ui-otp {\n  --ui-otp-bg: var(--aui-subtle, #1d1d20);\n  --ui-otp-card: var(--aui-surface, #19191b);\n  --ui-otp-line: var(--aui-border, #2a2a2e);\n  --ui-otp-line-strong: var(--aui-border-strong, #3a3a40);\n  --ui-otp-ink: var(--aui-ink, #ededed);\n  --ui-otp-text: var(--aui-ink, #c8c8cc);\n  --ui-otp-muted: var(--aui-muted, #8b8b92);\n  --ui-otp-faint: #4a4a50;\n  --ui-otp-halo: rgba(255, 255, 255, 0.08);\n  --ui-otp-wait: #1d1d20;\n  --ui-otp-shine: rgba(255, 255, 255, 0.07);\n  --ui-otp-danger: var(--aui-red, #ff6b5f);\n  --ui-otp-danger-line: color-mix(in srgb, var(--aui-red, #ff6b5f) 55%, transparent);\n  --ui-otp-danger-halo: color-mix(in srgb, var(--aui-red, #ff6b5f) 14%, transparent);\n  --ui-otp-success: var(--aui-green, #5fc88a);\n  --ui-otp-accent: var(--aui-blue, #4fb0fe);\n  --ui-otp-focus: var(--aui-focus, rgba(77, 141, 255, 0.8));\n}\n`;\n"}]}