{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-segmented-input",
  "type": "registry:component",
  "title": "OTP Segmented Input",
  "description": "Six cells that are secretly one real input, with native selection driving the active cell.",
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://lab.moumen.dev/r/lab-theme.json"
  ],
  "files": [
    {
      "path": "registry/lab/otp-segmented-input/otp-segmented-input.tsx",
      "type": "registry:component",
      "target": "@components/lab/otp-segmented-input.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useMemo, useRef, useState, type CSSProperties } from \"react\";\nimport { MotionConfig, motion } from \"motion/react\";\n\n// OTP segmented input - N boxes, secretly ONE real input.\n//\n// The version everyone demos is six <input>s wired together with JS focus\n// hops. It looks right and behaves wrong: SMS autofill can't fill it (iOS\n// offers the code to ONE field), paste needs bespoke splitting, screen\n// readers announce six unlabeled boxes, and half the keyboard is re-invented.\n//\n// This is the hard version: one real <input> stretched invisibly over the\n// whole row (color and caret transparent - NOT display:none, it must stay\n// focusable and autofillable), with the cells painted underneath from its\n// value. Everything hard becomes free:\n//\n//   · SMS autofill just works - autocomplete=\"one-time-code\" on a real,\n//     visible-to-the-browser input.\n//   · Paste just works - \"246 810\" lands in the input, one normalize pass\n//     strips the junk, the cells repaint.\n//   · Backspace walks backwards and ←/→ move the caret because they are the\n//     NATIVE caret - the active cell is derived from selectionStart, never\n//     stored beside it. Select-all paints all cells selected, because a\n//     selection range maps to a cell range.\n//   · The input's own glyphs are letter-spaced to sit under the cells, so the\n//     blueprint toggle can simply tint them red and you SEE the real input\n//     lying over the fake one.\n//\n// Verification is yours: pass `verify` (sync or async - hit your API) and a\n// full code drives the little state machine: right → the cells cascade green\n// left to right; wrong → the row shakes, the digits drop out one by one, then\n// the field clears and hands the caret back. Without `verify` it compares\n// against the `code` prop, so the component demos out of the box.\n//\n// Animation via motion/react; honours prefers-reduced-motion. Requires the\n// lab-theme tokens. Fully Tailwind, no CSS files.\n\nconst EASE = [0.22, 1, 0.36, 1] as const;\nconst SHAKE_S = 0.38; // wrong code: the row shake\nconst DROP_S = 0.24; // each digit's fall-out\nconst STAGGER_S = 0.045; // per-digit clear offset\nconst FILL_S = 0.055; // per-cell success cascade offset\n\nconst OTP_VARS = {\n  \"--otp-cell-w\": \"2.75rem\",\n  \"--otp-cell-h\": \"3.25rem\",\n  \"--otp-gap\": \"0.5rem\",\n} as CSSProperties;\n\nexport interface OtpInputState {\n  length: number;\n  caret: { start: number; end: number };\n  state: \"idle\" | \"success\" | \"error\";\n  attempts: number;\n  focused: boolean;\n}\n\nexport default function OtpInput({\n  length = 6,\n  code = \"246810\",\n  verify,\n  mask = false, // paint • instead of the digit\n  group = false, // split in half, like SMS codes read aloud\n  prefill = null, // {key, code} - simulate an autofill (keyed so re-picking re-applies)\n  inspect = false,\n  onStateChange,\n}: {\n  length?: number;\n  /** Demo fallback: the code `verify` defaults to comparing against. */\n  code?: string;\n  /** Your check - sync or async (hit your API); return whether the code is right. */\n  verify?: (value: string) => boolean | Promise<boolean>;\n  mask?: boolean;\n  group?: boolean;\n  prefill?: { key: number; code: string } | null;\n  inspect?: boolean;\n  onStateChange?: (state: OtpInputState) => void;\n}) {\n  const [value, setValue] = useState(\"\");\n  const [sel, setSel] = useState({ start: 0, end: 0 });\n  const [focused, setFocused] = useState(false);\n  const [state, setState] = useState<\"idle\" | \"success\" | \"error\">(\"idle\");\n  const [attempts, setAttempts] = useState(0);\n\n  const inputRef = useRef<HTMLInputElement>(null);\n  const errorTimerRef = useRef<number>(0);\n\n  const chars = value.split(\"\");\n  const collapsed = sel.start === sel.end;\n  const caretCell = Math.min(sel.start, length - 1);\n  const groupAt = Math.ceil(length / 2);\n\n  function syncSel() {\n    const el = inputRef.current;\n    if (!el) return;\n    setSel({ start: el.selectionStart ?? 0, end: el.selectionEnd ?? 0 });\n  }\n\n  // The active cell is DERIVED from the native selection - arrows, backspace,\n  // select-all all just move the real caret and the paint follows.\n  useEffect(() => {\n    const onSelectionChange = () => {\n      if (document.activeElement === inputRef.current) syncSel();\n    };\n    document.addEventListener(\"selectionchange\", onSelectionChange);\n    return () => document.removeEventListener(\"selectionchange\", onSelectionChange);\n  }, []);\n\n  function handleChange(event: React.ChangeEvent<HTMLInputElement>) {\n    if (state !== \"idle\") return;\n    // One normalize pass covers typing, paste and autofill: \"246 810\",\n    // \"246-810\" and \"246810\" all become the same digits.\n    const next = event.target.value.replace(/\\D/g, \"\").slice(0, length);\n    setValue(next);\n    requestAnimationFrame(syncSel);\n  }\n\n  // Native click mapping is the one thing that's wrong for OTP (you can't\n  // edit the middle of a code) - snap pointer focus to the end instead.\n  function handleMouseDown(event: React.MouseEvent) {\n    event.preventDefault();\n    const el = inputRef.current;\n    el?.focus({ preventScroll: true });\n    el?.setSelectionRange(value.length, value.length);\n    syncSel();\n  }\n\n  // A full code in → verify. A beat of delay so the last digit is seen landing\n  // before the row answers; the check itself may be async (your API).\n  useEffect(() => {\n    if (state !== \"idle\" || value.length !== length) return undefined;\n    let cancelled = false;\n    const timer = window.setTimeout(async () => {\n      let ok: boolean;\n      try {\n        ok = await Promise.resolve(verify ? verify(value) : value === code);\n      } catch {\n        ok = false;\n      }\n      if (cancelled) return;\n      setAttempts((n) => n + 1);\n      if (ok) {\n        setState(\"success\");\n      } else {\n        setState(\"error\");\n        // Shake, then the digits drop out one by one, then the field clears\n        // and the caret comes back for another try.\n        errorTimerRef.current = window.setTimeout(() => {\n          setValue(\"\");\n          setState(\"idle\");\n          const el = inputRef.current;\n          if (el && document.activeElement === el) {\n            el.setSelectionRange(0, 0);\n            syncSel();\n          } else {\n            setSel({ start: 0, end: 0 });\n          }\n        }, SHAKE_S * 1000 + length * STAGGER_S * 1000 + 260);\n      }\n    }, 320);\n    return () => {\n      cancelled = true;\n      clearTimeout(timer);\n    };\n  }, [value, state, code, verify, length]);\n\n  // Simulated autofill (e.g. demo presets) - through the same normalize +\n  // verify path a real autofill would take.\n  useEffect(() => {\n    if (!prefill) return;\n    clearTimeout(errorTimerRef.current);\n    setState(\"idle\");\n    setValue(String(prefill.code).replace(/\\D/g, \"\").slice(0, length));\n    setSel({ start: length, end: length });\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [prefill?.key]);\n\n  function reset() {\n    clearTimeout(errorTimerRef.current);\n    setValue(\"\");\n    setState(\"idle\");\n    setAttempts(0);\n    const el = inputRef.current;\n    el?.focus({ preventScroll: true });\n    el?.setSelectionRange(0, 0);\n    syncSel();\n  }\n\n  useEffect(() => () => clearTimeout(errorTimerRef.current), []);\n\n  useEffect(() => {\n    onStateChange?.({\n      length: value.length,\n      caret: { start: sel.start, end: sel.end },\n      state,\n      attempts,\n      focused,\n    });\n  }, [value.length, sel, state, attempts, focused, onStateChange]);\n\n  const cells = useMemo(\n    () =>\n      Array.from({ length }, (_, index) => {\n        const char = chars[index];\n        return {\n          index,\n          char,\n          active: focused && state === \"idle\" && collapsed && caretCell === index,\n          selected: focused && !collapsed && index >= sel.start && index < sel.end,\n        };\n      }),\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n    [chars.join(\"\"), length, focused, state, collapsed, caretCell, sel.start, sel.end],\n  );\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <div className=\"relative flex flex-col items-center gap-2\" style={OTP_VARS} data-state={state}>\n        {/* Wrong code: the row shakes once, as one object. */}\n        <motion.div\n          className=\"relative flex gap-[var(--otp-gap)]\"\n          animate={state === \"error\" ? { x: [0, -6, 5, -4, 3, -1, 0] } : { x: 0 }}\n          transition={{ duration: SHAKE_S, ease: EASE }}\n        >\n          {cells.map((cell) => (\n            <div\n              key={cell.index}\n              className={[\n                \"flex items-center justify-center w-[var(--otp-cell-w)] h-[var(--otp-cell-h)] rounded-[0.625rem] text-xl font-medium tabular-nums\",\n                \"transition-[box-shadow,background-color,color] duration-150\",\n                group && cell.index === groupAt ? \"ml-3\" : \"\",\n                // the success cascade retimes the tint with a per-cell delay\n                state === \"success\"\n                  ? \"shadow-[var(--shadow-border),0_0_0_1.5px_#16a34a] bg-[#f0fdf4] text-[#15803d]\"\n                  : cell.active\n                    ? \"bg-card text-foreground shadow-[var(--shadow-border),0_0_0_2px_var(--color-ring)]\"\n                    : state === \"error\"\n                      ? \"bg-card text-destructive shadow-border\"\n                      : \"bg-card text-foreground shadow-border\",\n                inspect ? \"outline outline-[1.5px] outline-dashed outline-[#3b82f6] -outline-offset-2\" : \"\",\n              ].join(\" \")}\n              style={state === \"success\" ? { transitionDelay: `${cell.index * FILL_S * 1000}ms` } : undefined}\n              aria-hidden=\"true\"\n            >\n              {cell.char && (\n                // A selection RANGE maps to a cell range - one input. The\n                // highlight hugs the digit like native text selection paints\n                // the glyph's line box, instead of tinting the whole slot.\n                // Padding is offset by negative margins so toggling it never\n                // shifts the centered glyph.\n                <motion.span\n                  className={`inline-block rounded-[0.3125rem] px-1 py-0.5 -mx-1 -my-0.5 transition-colors duration-150 ${\n                    cell.selected ? \"bg-foreground/10\" : \"bg-transparent\"\n                  }`}\n                  initial={false}\n                  animate={\n                    state === \"success\"\n                      ? { scale: [1, 1.15, 1], y: 0, opacity: 1, filter: \"blur(0px)\" }\n                      : state === \"error\"\n                        ? { y: \"0.5rem\", opacity: 0, filter: \"blur(2px)\" }\n                        : { scale: 1, y: 0, opacity: 1, filter: \"blur(0px)\" }\n                  }\n                  transition={\n                    state === \"success\"\n                      ? { duration: 0.3, ease: EASE, delay: cell.index * FILL_S }\n                      : state === \"error\"\n                        ? { duration: DROP_S, ease: \"easeOut\", delay: SHAKE_S + cell.index * STAGGER_S }\n                        : { duration: 0 }\n                  }\n                >\n                  {mask ? \"•\" : cell.char}\n                </motion.span>\n              )}\n              {cell.active && !cell.char && (\n                // The fake caret: a hard blink (steps, not a fade).\n                <motion.span\n                  className=\"w-[1.5px] h-[1.375rem] rounded-[1px] bg-foreground\"\n                  animate={{ opacity: [1, 1, 0, 0] }}\n                  transition={{ duration: 1.1, times: [0, 0.5, 0.5, 1], repeat: Infinity, ease: \"linear\" }}\n                />\n              )}\n            </div>\n          ))}\n\n          {/* THE component: one real input over the whole row. Transparent, not\n              hidden - the browser must see it to autofill and focus it. No\n              maxLength: it would truncate a formatted paste (\"246 810\" is 7\n              chars) BEFORE the normalize pass - the slice enforces length. */}\n          <input\n            ref={inputRef}\n            className={[\n              \"absolute inset-0 w-full h-full border-0 outline-none bg-transparent font-mono text-xl cursor-text\",\n              \"[letter-spacing:calc(var(--otp-cell-w)+var(--otp-gap)-1ch)] pl-[calc(var(--otp-cell-w)/2-0.5ch)]\",\n              // A host page's own ::selection styling repaints selected glyphs\n              // with a visible foreground - select-all would reveal masked\n              // digits (this site does exactly that: selection:text-white on\n              // the page wrapper, which Tailwind cascades to descendants at\n              // EQUAL specificity, so source order decides). `!` makes the\n              // component win deterministically in any host page; the\n              // text-fill-color below is the second lock - ::selection cannot\n              // override it, so the glyphs stay invisible mid-selection.\n              \"selection:bg-transparent! selection:text-transparent! [caret-color:transparent]\",\n              inspect\n                ? \"text-[rgba(220,38,38,0.55)] [-webkit-text-fill-color:rgba(220,38,38,0.55)] outline outline-[1.5px] outline-dashed outline-[#ef4444] outline-offset-4\" // the secret, revealed\n                : \"text-transparent [-webkit-text-fill-color:transparent]\",\n            ].join(\" \")}\n            type=\"text\"\n            value={value}\n            inputMode=\"numeric\"\n            autoComplete=\"one-time-code\"\n            aria-label={`${length}-digit verification code`}\n            spellCheck={false}\n            autoCorrect=\"off\"\n            readOnly={state !== \"idle\"}\n            onChange={handleChange}\n            onMouseDown={handleMouseDown}\n            onKeyUp={syncSel}\n            onFocus={() => {\n              setFocused(true);\n              const el = inputRef.current;\n              el?.setSelectionRange(value.length, value.length);\n              syncSel();\n            }}\n            onBlur={() => setFocused(false)}\n          />\n        </motion.div>\n\n        {/* Fixed-height under-row so Verified / Wrong code never shift the layout. */}\n        <div className=\"flex items-center gap-2.5 min-h-7\">\n          <span\n            className={`text-[0.8125rem] transition-colors duration-150 ${\n              state === \"success\" ? \"text-[#16a34a] font-medium\" : state === \"error\" ? \"text-destructive font-medium\" : \"text-muted-foreground/70\"\n            }`}\n          >\n            {state === \"success\" ? \"Verified\" : state === \"error\" ? \"Wrong code\" : mask ? \"Digits are masked\" : \" \"}\n          </span>\n          {state === \"success\" && (\n            <motion.button\n              type=\"button\"\n              className=\"h-7 px-2.5 rounded-lg bg-muted text-foreground text-xs font-medium cursor-pointer transition-[background-color,scale] duration-150 hover:bg-foreground/10 active:scale-[0.96] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n              onClick={reset}\n              initial={{ opacity: 0 }}\n              animate={{ opacity: 1 }}\n              transition={{ duration: 0.2, ease: [0.2, 0, 0, 1] }}\n            >\n              Try again\n            </motion.button>\n          )}\n        </div>\n\n        <span className=\"sr-only\" aria-live=\"polite\">\n          {state === \"success\" ? \"Code verified.\" : state === \"error\" ? \"Wrong code, the field will clear. Try again.\" : \"\"}\n        </span>\n\n        {/* Blueprint annotations - red is the real input revealed (its glyphs\n            are letter-spaced to sit under the cells), blue the derived paint. */}\n        {inspect && (\n          <>\n            <span className=\"absolute bottom-[calc(100%+0.75rem)] left-1/2 -translate-x-1/2 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#fecaca] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#dc2626] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums\">\n              one real input · color: transparent · autocomplete: one-time-code\n            </span>\n            <span className=\"absolute top-[calc(100%+0.4rem)] left-1/2 -translate-x-1/2 z-[6] whitespace-nowrap rounded-[0.25rem] border border-[#bfdbfe] bg-white px-[0.3125rem] py-[0.0625rem] text-[0.625rem] font-medium leading-normal tracking-[0.01em] text-[#2563eb] shadow-[0_1px_2px_rgba(0,0,0,0.08)] pointer-events-none tabular-nums\">\n              selection {sel.start}..{sel.end} → {collapsed ? `cell ${caretCell}` : `cells ${sel.start}-${Math.max(sel.start, sel.end - 1)}`}\n            </span>\n          </>\n        )}\n      </div>\n    </MotionConfig>\n  );\n}\n"
    }
  ]
}
