{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"otp-input","type":"registry:ui","title":"OTP input","description":"A segmented field for one-time codes with paste support and status.","dependencies":["clsx","tailwind-merge"],"registryDependencies":["https://caliperui.site/r/styles.json"],"files":[{"path":"ui/glide-highlight.tsx","type":"registry:ui","target":"components/ui/glide-highlight.tsx","content":"\"use client\";\n\nimport { useLayoutEffect, useState } from \"react\";\nimport \"./glide-highlight.css\";\n\nexport type GlideBox = {\n  active: boolean;\n  left: number;\n  top: number;\n  width: number;\n  height: number;\n};\n\nexport function glideBox(target: HTMLElement): GlideBox {\n  return {\n    active: true,\n    left: target.offsetLeft,\n    top: target.offsetTop,\n    width: target.offsetWidth,\n    height: target.offsetHeight,\n  };\n}\n\nexport function useHighlightedGlide(\n  root: HTMLElement | null,\n  selector = \"[data-highlighted]\",\n  /** Attributes whose change can move the target, e.g. data-state for an open trigger. */\n  attributes: readonly string[] = [\"data-highlighted\"],\n) {\n  const [box, setBox] = useState<GlideBox | null>(null);\n\n  useLayoutEffect(() => {\n    const element = root;\n    if (!element) return;\n    const update = () => {\n      const target = element.querySelector<HTMLElement>(selector);\n      setBox((current) =>\n        target ? glideBox(target) : current ? { ...current, active: false } : null,\n      );\n    };\n    update();\n    const observer = new MutationObserver(update);\n    observer.observe(element, {\n      subtree: true,\n      attributes: true,\n      attributeFilter: [...attributes],\n    });\n    return () => observer.disconnect();\n    // The attribute list is fixed per call site.\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [root, selector]);\n\n  return box;\n}\n\nexport function GlideHighlight({ box }: { box: GlideBox | null }) {\n  if (!box) return null;\n  return (\n    <span\n      aria-hidden=\"true\"\n      data-slot=\"glide-highlight\"\n      data-active={box.active}\n      style={{\n        width: box.width,\n        height: box.height,\n        transform: `translate(${box.left}px, ${box.top}px)`,\n      }}\n    />\n  );\n}\n"},{"path":"ui/label.tsx","type":"registry:ui","target":"components/ui/label.tsx","content":"import type { ComponentProps } from \"react\";\nimport { cn } from \"./lib/cn\";\n\nexport function Label({ className, ...props }: ComponentProps<\"label\">) {\n  return (\n    <label\n      data-slot=\"label\"\n      className={cn(\n        \"inline-block max-w-full text-sm leading-5 font-medium [overflow-wrap:anywhere]\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n"},{"path":"ui/lib/cn.ts","type":"registry:ui","target":"components/ui/lib/cn.ts","content":"import { clsx, type ClassValue } from \"clsx\";\nimport { extendTailwindMerge } from \"tailwind-merge\";\n\nconst twMerge = extendTailwindMerge({\n  extend: { theme: { radius: [\"control\", \"surface\", \"overlay\"], shadow: [\"overlay\"] } },\n});\n\nexport function cn(...inputs: ClassValue[]) {\n  return twMerge(clsx(inputs));\n}\n"},{"path":"ui/otp-input.tsx","type":"registry:ui","target":"components/ui/otp-input.tsx","content":"\"use client\";\n\nimport { useLayoutEffect, useRef, useState } from \"react\";\nimport { GlideHighlight, type GlideBox } from \"./glide-highlight\";\nimport { Label } from \"./label\";\nimport { cn } from \"./lib/cn\";\nimport \"./otp-input.css\";\n\nexport function OtpInput({\n  label = \"One-time code\",\n  length = 6,\n  value,\n  defaultValue = \"\",\n  onValueChange,\n  status = \"idle\",\n  message,\n  disabled,\n  name,\n  className,\n}: {\n  label?: string;\n  length?: number;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  status?: \"idle\" | \"error\" | \"success\";\n  /** Replaces the status line, e.g. why a code was refused or “Checking the code…”. */\n  message?: string;\n  disabled?: boolean;\n  name?: string;\n  className?: string;\n}) {\n  const [local, setLocal] = useState(defaultValue);\n  const [active, setActive] = useState(0);\n  const [box, setBox] = useState<GlideBox | null>(null);\n  const inputs = useRef<Array<HTMLInputElement | null>>([]);\n  const shell = useRef<HTMLDivElement>(null);\n  const current = (value ?? local).slice(0, length);\n  const live = useRef(current);\n  const digits = Array.from({ length }, (_, index) => current[index] ?? \"\");\n\n  function commit(next: string) {\n    const clipped = next.replace(/\\D/g, \"\").slice(0, length);\n    live.current = clipped;\n    if (value == null) setLocal(clipped);\n    onValueChange?.(clipped);\n  }\n\n  useLayoutEffect(() => {\n    live.current = current;\n    const root = shell.current;\n    const field = inputs.current[active];\n    if (!root || !field) return;\n    const rootRect = root.getBoundingClientRect();\n    const rect = field.getBoundingClientRect();\n    setBox({\n      active: true,\n      left: rect.left - rootRect.left,\n      top: rect.top - rootRect.top,\n      width: rect.width,\n      height: rect.height,\n    });\n  }, [active, current]);\n\n  return (\n    <div className={cn(\"grid gap-2\", className)}>\n      <Label>{label}</Label>\n      <div ref={shell} data-slot=\"otp\" data-invalid={status === \"error\" || undefined} data-success={status === \"success\" || undefined} className=\"relative grid w-fit max-w-full gap-2\" style={{ gridTemplateColumns: `repeat(${length}, minmax(0, 2.75rem))` }}>\n        <GlideHighlight box={box} />\n        {digits.map((digit, index) => (\n          <span key={index} className=\"grid min-w-0\">\n          <input\n            ref={(node) => {\n              inputs.current[index] = node;\n            }}\n            inputMode=\"numeric\"\n            autoComplete={index === 0 ? \"one-time-code\" : \"off\"}\n            aria-label={`${label} digit ${index + 1}`}\n            disabled={disabled}\n            value={digit}\n            maxLength={1}\n            className=\"z-10 col-start-1 row-start-1 h-11 w-full min-w-0 rounded-control bg-[var(--field)] shadow-[var(--shadow-field)] transition-shadow hover:shadow-[var(--shadow-field-hover)] text-center text-sm font-medium text-transparent tabular-nums caret-transparent outline-none focus-visible:shadow-[var(--shadow-field-focus)]\"\n            onFocus={() => setActive(index)}\n            onChange={(event) => {\n              const text = event.currentTarget.value.replace(/\\D/g, \"\");\n              const valueNow = live.current;\n              if (text.length > 1) {\n                commit(`${valueNow.slice(0, index)}${text}`);\n                const next = Math.min(length - 1, index + text.length);\n                inputs.current[next]?.focus();\n                return;\n              }\n              const nextValue = `${valueNow.slice(0, index)}${text}${valueNow.slice(index + 1)}`;\n              commit(nextValue);\n              if (text) inputs.current[Math.min(length - 1, index + 1)]?.focus();\n            }}\n            onKeyDown={(event) => {\n              if (event.key === \"Backspace\" && !live.current[index]) {\n                event.preventDefault();\n                const previous = Math.max(0, index - 1);\n                commit(`${live.current.slice(0, previous)}${live.current.slice(previous + 1)}`);\n                inputs.current[previous]?.focus();\n              } else if (event.key === \"ArrowLeft\") {\n                inputs.current[Math.max(0, index - 1)]?.focus();\n              } else if (event.key === \"ArrowRight\") {\n                inputs.current[Math.min(length - 1, index + 1)]?.focus();\n              }\n            }}\n            onPaste={(event) => {\n              event.preventDefault();\n              const text = event.clipboardData.getData(\"text\").replace(/\\D/g, \"\");\n              commit(`${live.current.slice(0, index)}${text}`);\n              inputs.current[Math.min(length - 1, index + text.length)]?.focus();\n            }}\n          />\n          <span aria-hidden=\"true\" data-slot=\"otp-digit\" key={digit || \"empty\"} className=\"pointer-events-none z-20 col-start-1 row-start-1 grid place-items-center text-sm font-medium text-foreground\">\n            {digit}\n          </span>\n          </span>\n        ))}\n        </div>\n      {name ? <input type=\"hidden\" name={name} value={current} /> : null}\n      <p role=\"status\" className={cn(\"min-h-5 text-xs\", status === \"error\" ? \"text-destructive\" : \"text-muted-foreground\")}>\n        {message ?? (status === \"error\" ? \"That code was not accepted.\" : status === \"success\" ? \"Code accepted.\" : `${current.length} of ${length} digits.`)}\n      </p>\n    </div>\n  );\n}\n"},{"path":"ui/glide-highlight.css","type":"registry:file","target":"components/ui/glide-highlight.css","content":"/* glide-highlight.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"glide-highlight\"] {\n    position: absolute;\n    top: 0;\n    left: 0;\n    z-index: 0;\n    pointer-events: none;\n    border-radius: var(--radius-surface);\n    background: var(--muted);\n    opacity: 0;\n    transition:\n      transform var(--duration-travel) var(--ease-showcase),\n      width var(--duration-travel) var(--ease-showcase),\n      height var(--duration-travel) var(--ease-showcase),\n      opacity var(--duration-deliberate) var(--ease-out);\n  }\n  [data-slot=\"glide-highlight\"][data-active=\"true\"] {\n    opacity: 1;\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"glide-highlight\"] {\n      transition: none;\n    }\n    [data-slot=\"glide-highlight\"] {\n      transition:\n        transform 400ms var(--ease-showcase),\n        width 400ms var(--ease-showcase),\n        height 400ms var(--ease-showcase),\n        opacity 280ms var(--ease-out);\n    }\n  }\n}\n"},{"path":"ui/otp-input.css","type":"registry:file","target":"components/ui/otp-input.css","content":"/* otp-input.tsx's styles; it imports this file. Edit freely. */\n@layer theme, base, components, utilities;\n\n@layer components {\n  [data-slot=\"otp\"][data-invalid=\"true\"] {\n    animation: caliper-reject var(--duration-travel) var(--ease-out);\n  }\n  [data-slot=\"otp\"] [data-slot=\"glide-highlight\"] {\n    border-radius: var(--radius-overlay);\n    background: transparent;\n    box-shadow: 0 0 0 2px var(--foreground);\n  }\n  [data-slot=\"otp-digit\"]:not(:empty) {\n    animation: caliper-digit-in var(--duration-travel) var(--ease-showcase);\n  }\n\n  @media (prefers-reduced-motion: reduce) {\n    [data-slot=\"otp-digit\"]:not(:empty) {\n      animation: caliper-digit-in 400ms var(--ease-showcase);\n    }\n    [data-slot=\"otp\"][data-invalid=\"true\"] {\n      animation: caliper-reject 400ms var(--ease-out);\n    }\n  }\n}\n"}],"categories":["inputs"],"meta":{"tier":"free","license":"MIT","family":"otp","docs":"/docs/components/otp-input"}}