{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-input",
  "type": "registry:block",
  "title": "OTP input",
  "description": "A six digit verification flow with paste support and keyboard navigation.",
  "categories": [
    "blocks"
  ],
  "dependencies": [
    "motion"
  ],
  "registryDependencies": [
    "https://irisui.dev/r/foundation.json",
    "https://irisui.dev/r/utils.json",
    "https://irisui.dev/r/motion-tokens.json"
  ],
  "files": [
    {
      "path": "src/registry/components/otp-input/otp-input.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useId, useRef, useState } from \"react\";\nimport type { ClipboardEvent, KeyboardEvent, ChangeEvent, FocusEvent } from \"react\";\nimport { AnimatePresence, motion, useAnimate, useReducedMotion } from \"motion/react\";\nimport type { Transition } from \"motion/react\";\nimport { cn } from \"../lib/utils\";\nimport { motionTokens } from \"../lib/motion-tokens\";\n\nexport interface OtpInputProps {\n  length?: number;\n  value?: string;\n  onChange?: (value: string) => void;\n  label: string;\n  description?: string;\n  error?: string;\n  autoFocus?: boolean;\n  disabled?: boolean;\n  inputMode?: \"numeric\" | \"text\";\n  className?: string;\n}\n\n/** Changed words rise in and unblur while unchanged words hold still. Assistive tech reads the plain copy. */\nfunction MotionText({ text }: { text: string }) {\n  const reduced = useReducedMotion();\n  const words = text.split(\" \");\n  return (\n    <>\n      <span className=\"sr-only\">{text}</span>\n      {/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */}\n      <span className=\"relative block\" aria-hidden=\"true\">\n        <AnimatePresence initial={false} mode=\"popLayout\">\n          {words.map((word, index) => (\n            <motion.span\n              key={`${index}:${word}`}\n              className=\"inline-block whitespace-pre\"\n              initial={\n                reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n              }\n              animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n              exit={\n                reduced\n                  ? { opacity: 0, transition: { duration: 0 } }\n                  : {\n                      opacity: 0,\n                      y: \"-0.35em\",\n                      filter: `blur(${motionTokens.blur.subtle}px)`,\n                      transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] },\n                    }\n              }\n              transition={\n                reduced\n                  ? { duration: motionTokens.duration.instant }\n                  : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }\n              }\n            >\n              {index < words.length - 1 ? `${word} ` : word}\n            </motion.span>\n          ))}\n        </AnimatePresence>\n      </span>\n    </>\n  );\n}\n\n/** Helper and error copy: the row opens its height on a spring, then the words settle in. */\nfunction FieldMessage({\n  id,\n  text,\n  className,\n  alert,\n}: {\n  id?: string;\n  text?: string;\n  className?: string;\n  alert?: boolean;\n}) {\n  return (\n    <AnimatePresence initial={false}>\n      {text ? <MessageRow key=\"message\" id={id} text={text} className={className} alert={alert} /> : null}\n    </AnimatePresence>\n  );\n}\n\n/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction MessageRow({\n  id,\n  text,\n  className,\n  alert,\n}: {\n  id?: string;\n  text: string;\n  className?: string;\n  alert?: boolean;\n}) {\n  const reduced = useReducedMotion();\n  const copyRef = useRef<HTMLSpanElement>(null);\n  const [height, setHeight] = useState<number | \"auto\">(\"auto\");\n  useEffect(() => {\n    const node = copyRef.current;\n    if (!node || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(([entry]) =>\n      setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight),\n    );\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  // The row's space is padding inside the clipped slot, so the height animates from a true zero.\n  return (\n    <motion.span\n      className=\"block overflow-hidden\"\n      initial={{ height: 0, opacity: 0 }}\n      animate={{ height, opacity: 1 }}\n      exit={{\n        height: 0,\n        opacity: 0,\n        transition: reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } },\n      }}\n      transition={\n        reduced\n          ? { duration: 0 }\n          : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }\n      }\n    >\n      <motion.span\n        ref={copyRef}\n        id={id}\n        className={className}\n        role={alert ? \"alert\" : undefined}\n        initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }}\n        animate={{ y: 0, filter: \"blur(0px)\" }}\n        transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}\n      >\n        <MotionText text={text} />\n      </motion.span>\n    </motion.span>\n  );\n}\n\nexport function OtpInput({\n  length = 6,\n  value = \"\",\n  onChange,\n  label,\n  description,\n  error,\n  autoFocus = false,\n  disabled = false,\n  inputMode = \"numeric\",\n  className,\n}: OtpInputProps) {\n  const generatedId = useId();\n  const reduced = useReducedMotion();\n  const [scope, animate] = useAnimate<HTMLFieldSetElement>();\n  const inputRefs = useRef<Array<HTMLInputElement | null>>([]);\n  const values = Array.from({ length }, (_, index) => value[index] ?? \"\");\n  const hintId = description ? `${generatedId}-description` : undefined;\n  const errorId = error ? `${generatedId}-error` : undefined;\n  const describedBy = [hintId, errorId].filter(Boolean).join(\" \") || undefined;\n  const focusAt = (index: number) => inputRefs.current[Math.max(0, Math.min(index, length - 1))]?.focus();\n  const sanitize = (next: string) => (inputMode === \"numeric\" ? next.replace(/\\D/g, \"\") : next);\n  // One ring for the whole row. It glides between slots while focus stays inside, and fades in where focus lands when it arrives from outside.\n  const [ring, setRing] = useState({ x: 0, width: 0, shown: false, glide: false });\n  // A paste fills several slots in one update; those digits land as a short left to right wave.\n  const [fill, setFill] = useState({ value, wave: false });\n  if (fill.value !== value)\n    setFill({\n      value,\n      wave:\n        Array.from({ length }, (_, index) => (fill.value[index] ?? \"\") !== (value[index] ?? \"\")).filter(Boolean)\n          .length > 1,\n    });\n  const lastError = useRef(error);\n\n  useEffect(() => {\n    if (autoFocus) inputRefs.current[0]?.focus();\n  }, [autoFocus]);\n\n  // Slots shrink at narrow widths; keep the ring on the focused slot when the row resizes.\n  useEffect(() => {\n    const row = scope.current;\n    if (!row || typeof ResizeObserver === \"undefined\") return;\n    const observer = new ResizeObserver(() => {\n      const slot = (\n        row.contains(document.activeElement) ? document.activeElement?.parentElement : null\n      ) as HTMLElement | null;\n      if (slot)\n        setRing((current) =>\n          current.x === slot.offsetLeft && current.width === slot.offsetWidth\n            ? current\n            : { ...current, x: slot.offsetLeft, width: slot.offsetWidth, glide: false },\n        );\n    });\n    observer.observe(row);\n    return () => observer.disconnect();\n  }, [scope]);\n\n  // A new error nudges the row side to side once, so a rejected code reads as a response to the attempt.\n  useEffect(() => {\n    if (error && error !== lastError.current && !reduced && scope.current)\n      animate(scope.current, { x: [0, -6, 5, -3, 2, 0] }, { duration: 0.36, ease: [...motionTokens.ease.standard] });\n    lastError.current = error;\n  }, [error, reduced, animate, scope]);\n\n  const updateAt = (index: number, raw: string) => {\n    const next = values.slice();\n    const clean = sanitize(raw).slice(-1);\n    next[index] = clean;\n    onChange?.(next.join(\"\"));\n    if (clean && index < length - 1) focusAt(index + 1);\n  };\n\n  const handleChange = (index: number, event: ChangeEvent<HTMLInputElement>) => {\n    const raw = sanitize(event.target.value);\n    if (raw.length <= 1) {\n      updateAt(index, raw);\n      return;\n    }\n\n    const next = values.slice();\n    raw\n      .slice(0, length - index)\n      .split(\"\")\n      .forEach((character, offset) => {\n        next[index + offset] = character;\n      });\n    onChange?.(next.join(\"\"));\n    focusAt(Math.min(index + raw.length, length - 1));\n  };\n  const handlePaste = (index: number, event: ClipboardEvent<HTMLInputElement>) => {\n    event.preventDefault();\n    const pasted = sanitize(event.clipboardData.getData(\"text\")).slice(0, length - index);\n    if (!pasted) return;\n    const next = values.slice();\n    pasted.split(\"\").forEach((character, offset) => {\n      next[index + offset] = character;\n    });\n    onChange?.(next.join(\"\"));\n    focusAt(Math.min(index + pasted.length, length - 1));\n  };\n\n  const handleKeyDown = (index: number, event: KeyboardEvent<HTMLInputElement>) => {\n    if (event.key === \"ArrowLeft\") {\n      event.preventDefault();\n      focusAt(index - 1);\n    }\n    if (event.key === \"ArrowRight\") {\n      event.preventDefault();\n      focusAt(index + 1);\n    }\n    if (event.key === \"Backspace\" && !values[index] && index > 0) {\n      event.preventDefault();\n      const next = values.slice();\n      next[index - 1] = \"\";\n      onChange?.(next.join(\"\"));\n      focusAt(index - 1);\n    }\n    if (event.key === \"Delete\" && values[index]) {\n      const next = values.slice();\n      next[index] = \"\";\n      onChange?.(next.join(\"\"));\n    }\n  };\n  const handleFocus = (event: FocusEvent<HTMLInputElement>) => {\n    const slot = event.currentTarget.parentElement as HTMLElement;\n    setRing((current) => ({ x: slot.offsetLeft, width: slot.offsetWidth, shown: true, glide: current.shown }));\n  };\n  const handleBlur = (event: FocusEvent<HTMLInputElement>) => {\n    if (!scope.current?.contains(event.relatedTarget as Node | null))\n      setRing((current) => ({ ...current, shown: false, glide: false }));\n  };\n  const glyphTransition = (index: number): Transition =>\n    reduced\n      ? { duration: motionTokens.duration.instant }\n      : {\n          duration: motionTokens.duration.standard,\n          ease: [...motionTokens.ease.enter],\n          delay: fill.wave ? index * motionTokens.stagger.item : 0,\n        };\n\n  return (\n    <div className={cn(\"grid min-w-0\", className)}>\n      <span className=\"mb-2 text-sm leading-body font-medium text-ink\">{label}</span>\n      <fieldset\n        ref={scope}\n        className=\"group/otp relative m-0 flex w-fit max-w-full min-w-0 gap-2 border-0 p-0 [@media(max-width:360px)]:gap-1.5\"\n        aria-label={label}\n        data-invalid={error ? \"true\" : undefined}\n      >\n        {values.map((character, index) => (\n          <div\n            key={`${generatedId}-${index}`}\n            className=\"relative grid h-12 w-[42px] min-w-0 flex-[0_1_42px] [@media(max-width:360px)]:h-11\"\n          >\n            <input\n              ref={(node) => {\n                inputRefs.current[index] = node;\n              }}\n              // The native text is transparent (the caret stays visible); the glyph span draws the digit so it can rise in.\n              className=\"peer size-full min-w-0 rounded-control border border-line bg-surface text-center text-lg/[inherit] text-transparent tabular-nums caret-accent outline-none transition-[border-color,background-color] duration-fast ease-standard hover:enabled:not-data-[filled=true]:not-aria-[invalid=true]:border-line-strong hover:enabled:not-data-[filled=true]:not-aria-[invalid=true]:bg-hover focus-visible:outline-none disabled:cursor-not-allowed disabled:bg-hover disabled:opacity-48 aria-[invalid=true]:border-red aria-[invalid=true]:enabled:bg-[color-mix(in_srgb,var(--red)_7%,var(--surface))] data-[filled=true]:not-aria-[invalid=true]:border-accent data-[filled=true]:not-aria-[invalid=true]:enabled:bg-accent-tint motion-reduce:transition-none forced-colors:text-[CanvasText] forced-colors:focus-visible:[outline:2px_solid_CanvasText] forced-colors:focus-visible:outline-offset-2\"\n              aria-label={`${label}, digit ${index + 1} of ${length}`}\n              aria-describedby={describedBy}\n              aria-invalid={error ? true : undefined}\n              value={character}\n              data-filled={character ? \"true\" : undefined}\n              maxLength={1}\n              inputMode={inputMode}\n              autoComplete={index === 0 ? \"one-time-code\" : \"off\"}\n              disabled={disabled}\n              onChange={(event) => handleChange(index, event)}\n              onPaste={(event) => handlePaste(index, event)}\n              onKeyDown={(event) => handleKeyDown(index, event)}\n              onFocus={handleFocus}\n              onBlur={handleBlur}\n            />\n            {/* The input text is transparent; this copy of the digit rises into the slot and unblurs. */}\n            <AnimatePresence initial={false}>\n              {character ? (\n                <motion.span\n                  key={character}\n                  className=\"pointer-events-none absolute inset-0 grid place-items-center text-lg/[inherit] text-ink tabular-nums peer-disabled:opacity-48 forced-colors:hidden\"\n                  aria-hidden=\"true\"\n                  initial={\n                    reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }\n                  }\n                  animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n                  exit={\n                    reduced\n                      ? { opacity: 0, transition: { duration: 0 } }\n                      : {\n                          opacity: 0,\n                          scale: 0.9,\n                          filter: `blur(${motionTokens.blur.subtle}px)`,\n                          transition: {\n                            duration: motionTokens.duration.instant,\n                            ease: [...motionTokens.ease.standard],\n                          },\n                        }\n                  }\n                  transition={glyphTransition(index)}\n                >\n                  {character}\n                </motion.span>\n              ) : null}\n            </AnimatePresence>\n          </div>\n        ))}\n        {/* One ring per field. It glides to the focused slot and morphs to the danger color with the error state. */}\n        <motion.span\n          className=\"pointer-events-none absolute top-0 left-0 z-2 h-full rounded-control border border-accent-ink transition-[border-color] duration-fast ease-standard group-data-[invalid=true]/otp:border-red motion-reduce:transition-none forced-colors:hidden\"\n          aria-hidden=\"true\"\n          initial={false}\n          animate={{ x: ring.x, width: ring.width, opacity: ring.shown ? 1 : 0, scale: ring.shown ? 1 : 0.94 }}\n          transition={\n            reduced\n              ? { duration: 0 }\n              : {\n                  x: ring.glide ? motionTokens.spring.snappy : { duration: 0 },\n                  width: { duration: 0 },\n                  scale: motionTokens.spring.snappy,\n                  opacity: { duration: motionTokens.duration.fast },\n                }\n          }\n        />\n      </fieldset>\n      <FieldMessage id={hintId} text={description} className=\"block pt-2 text-xs leading-body text-ink-3\" />\n      <FieldMessage id={errorId} text={error} className=\"block pt-2 text-xs leading-body text-red\" alert />\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "@components/iris/otp-input/otp-input.tsx"
    }
  ],
  "docs": "Docs and live preview: https://irisui.dev/components/otp-input",
  "meta": {
    "kind": "block",
    "group": "Inputs",
    "export": "OtpInput",
    "docs": "https://irisui.dev/components/otp-input"
  }
}
