{"$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://ui.teamofsilicons.com/r/silicon-motion-tokens.json"],"files":[{"path":"registry/components/otp-input/otp-input.tsx","type":"registry:component","target":"@components/silicon-ui/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 { motionTokens } from \"../lib/motion-tokens\";\nimport styles from \"./otp-input.module.css\";\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 <><span className={styles.srOnly}>{text}</span><span className={styles.words} aria-hidden=\"true\"><AnimatePresence initial={false} mode=\"popLayout\">{words.map((word, index) => <motion.span key={`${index}:${word}`} className={styles.word}\n    initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }}\n    exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: \"-0.35em\", filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }}\n    transition={reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{index < words.length - 1 ? `${word} ` : word}</motion.span>)}</AnimatePresence></span></>;\n}\n\n/** Helper and error copy: the row opens its height on a spring, then the words settle in. */\nfunction FieldMessage({ id, text, className, alert }: { id?: string; text?: string; className?: string; alert?: boolean }) {\n  return <AnimatePresence initial={false}>{text ? <MessageRow key=\"message\" id={id} text={text} className={className} alert={alert} /> : null}</AnimatePresence>;\n}\n\n/** The row tracks the measured copy, so a longer message that wraps opens its next line instead of snapping. */\nfunction MessageRow({ id, text, className, alert }: { id?: string; text: string; className?: string; alert?: boolean }) {\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]) => setHeight(entry?.borderBoxSize?.[0]?.blockSize ?? node.offsetHeight));\n    observer.observe(node);\n    return () => observer.disconnect();\n  }, []);\n  return <motion.span className={styles.messageSlot} initial={{ height: 0, opacity: 0 }} animate={{ height, opacity: 1 }} exit={{ height: 0, opacity: 0, transition: reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.instant } } }} transition={reduced ? { duration: 0 } : { height: motionTokens.spring.smooth, opacity: { duration: motionTokens.duration.fast } }}>\n    <motion.span ref={copyRef} id={id} className={className} role={alert ? \"alert\" : undefined} initial={reduced ? false : { y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ y: 0, filter: \"blur(0px)\" }} transition={{ duration: reduced ? 0 : motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}><MotionText text={text} /></motion.span>\n  </motion.span>;\n}\n\nexport function OtpInput({ length = 6, value = \"\", onChange, label, description, error, autoFocus = false, disabled = false, inputMode = \"numeric\", className }: OtpInputProps) {\n  const generatedId = useId();\n  const reduced = useReducedMotion();\n  const [scope, animate] = useAnimate<HTMLDivElement>();\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) setFill({ value, wave: Array.from({ length }, (_, index) => (fill.value[index] ?? \"\") !== (value[index] ?? \"\")).filter(Boolean).length > 1 });\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 = (row.contains(document.activeElement) ? document.activeElement?.parentElement : null) as HTMLElement | null;\n      if (slot) setRing(current => (current.x === slot.offsetLeft && current.width === slot.offsetWidth ? current : { ...current, x: slot.offsetLeft, width: slot.offsetWidth, glide: false }));\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) animate(scope.current, { x: [0, -6, 5, -3, 2, 0] }, { duration: .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.slice(0, length - index).split(\"\").forEach((character, offset) => { next[index + offset] = character; });\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) => { next[index + offset] = character; });\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\") { event.preventDefault(); focusAt(index - 1); }\n    if (event.key === \"ArrowRight\") { event.preventDefault(); focusAt(index + 1); }\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>) => { const slot = event.currentTarget.parentElement as HTMLElement; setRing(current => ({ x: slot.offsetLeft, width: slot.offsetWidth, shown: true, glide: current.shown })); };\n  const handleBlur = (event: FocusEvent<HTMLInputElement>) => { if (!scope.current?.contains(event.relatedTarget as Node | null)) setRing(current => ({ ...current, shown: false, glide: false })); };\n  const glyphTransition = (index: number): Transition => reduced ? { duration: motionTokens.duration.instant } : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay: fill.wave ? index * motionTokens.stagger.item : 0 };\n\n  return <div className={[styles.field, className].filter(Boolean).join(\" \")}>\n    <span className={styles.label}>{label}</span>\n    <div ref={scope} className={styles.inputs} role=\"group\" aria-label={label} data-invalid={error ? \"true\" : undefined}>\n      {values.map((character, index) => <div key={`${generatedId}-${index}`} className={styles.slot}>\n        <input\n          ref={(node) => { inputRefs.current[index] = node; }}\n          className={styles.input}\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}>{character ? <motion.span key={character} className={styles.glyph} aria-hidden=\"true\" initial={reduced ? { opacity: 0 } : { opacity: 0, y: \"0.35em\", filter: `blur(${motionTokens.blur.soft}px)` }} animate={{ opacity: 1, y: 0, filter: \"blur(0px)\" }} exit={reduced ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, scale: .9, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } }} transition={glyphTransition(index)}>{character}</motion.span> : null}</AnimatePresence>\n      </div>)}\n      <motion.span className={styles.ring} aria-hidden=\"true\" initial={false} animate={{ x: ring.x, width: ring.width, opacity: ring.shown ? 1 : 0, scale: ring.shown ? 1 : .94 }} transition={reduced ? { duration: 0 } : { x: ring.glide ? motionTokens.spring.snappy : { duration: 0 }, width: { duration: 0 }, scale: motionTokens.spring.snappy, opacity: { duration: motionTokens.duration.fast } }} />\n    </div>\n    <FieldMessage id={hintId} text={description} className={styles.description} />\n    <FieldMessage id={errorId} text={error} className={styles.error} alert />\n  </div>;\n}\n"},{"path":"registry/components/otp-input/otp-input.module.css","type":"registry:component","target":"@components/silicon-ui/otp-input/otp-input.module.css","content":"/* No row gap: grid tracks clamp a negative margin at zero, so a closed message row would still pay the gap. Each row carries its own space instead. */\n.field { display: grid; min-width: 0; }\n.label { margin-bottom: var(--space-related); color: var(--foreground); font-size: var(--text-sm); font-weight: 500; line-height: var(--leading-body); }\n.inputs { position: relative; display: flex; width: fit-content; max-width: 100%; gap: var(--space-related); }\n.slot { position: relative; display: grid; width: 42px; height: 48px; min-width: 0; flex: 0 1 42px; }\n/* The native text is transparent (caret stays visible); .glyph draws the digit so it can rise in. */\n.input { width: 100%; height: 100%; min-width: 0; border: 1px solid var(--border); border-radius: var(--radius-control); background: var(--surface); color: transparent; caret-color: var(--accent); font: inherit; font-size: var(--text-lg); font-variant-numeric: tabular-nums; text-align: center; outline: none; transition: border-color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }\n@media (hover: hover) and (pointer: fine) { .input:hover:not(:disabled, [data-filled=\"true\"], [aria-invalid=\"true\"]) { border-color: var(--border-strong); background: var(--surface-muted); } }\n.input[data-filled=\"true\"] { border-color: var(--accent); background: var(--accent-subtle); }\n.input:focus-visible { outline: none; }\n.input[aria-invalid=\"true\"] { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 7%, var(--surface)); }\n.input:disabled { cursor: not-allowed; opacity: .48; background: var(--surface-muted); }\n.glyph { position: absolute; inset: 0; display: grid; place-items: center; color: var(--foreground); font-size: var(--text-lg); font-variant-numeric: tabular-nums; pointer-events: none; }\n.input:disabled ~ .glyph { opacity: .48; }\n/* One ring per field. It glides to the focused slot and morphs to the danger color with the error state. */\n.ring { position: absolute; top: 0; left: 0; height: 100%; z-index: 2; border: 1px solid var(--accent-strong); border-radius: var(--radius-control); pointer-events: none; transition: border-color var(--duration-fast) var(--ease-standard); }\n.inputs[data-invalid=\"true\"] .ring { border-color: var(--danger); }\n/* The row's space is padding inside the clipped slot, so the height animates from a true zero. */\n.messageSlot { display: block; overflow: hidden; }\n.description, .error { display: block; padding-top: var(--space-related); font-size: var(--text-xs); line-height: var(--leading-body); }\n.description { color: var(--text-muted); }.error { color: var(--danger); }\n/* Words are measured against this box when they pop out to leave, so it must be the positioned parent. */\n.words { position: relative; display: block; }\n.word { display: inline-block; white-space: pre; }\n.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n@media (max-width: 360px) { .inputs { gap: var(--space-related); }.slot { height: 44px; } }\n@media (forced-colors: active) { .ring { display: none; } .input:focus-visible { outline: 2px solid CanvasText; outline-offset: 2px; } .input { color: CanvasText; } .glyph { display: none; } }\n@media (prefers-reduced-motion: reduce) { .input, .ring { transition: none; } }\n"}],"docs":"Docs and live preview: https://ui.teamofsilicons.com/otp-input","meta":{"tier":"free","kind":"block","docs":"https://ui.teamofsilicons.com/otp-input","markdown":"https://ui.teamofsilicons.com/otp-input.md","status":"stable"}}