{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-field",
  "type": "registry:ui",
  "title": "OTP Field",
  "description": "Six cells that take paste and autofill, ripple while checking, clear on failure.",
  "categories": [
    "inputs"
  ],
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://www.stealth.pm/r/tokens.json",
    "https://www.stealth.pm/r/cn.json",
    "https://www.stealth.pm/r/icons.json",
    "https://www.stealth.pm/r/motion.json",
    "https://www.stealth.pm/r/use-controllable-state.json"
  ],
  "files": [
    {
      "path": "components/ui/otp-field.tsx",
      "content": "\"use client\";\nimport { OTPField as Base } from \"@base-ui/react/otp-field\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { Fragment, useEffect, useEffectEvent, useId, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/cn\";\nimport { Alert } from \"@/lib/icons\";\nimport { ease, spring } from \"@/lib/motion\";\nimport { useControllableState } from \"@/lib/use-controllable-state\";\n\nexport type OTPStatus = \"idle\" | \"verifying\" | \"success\" | \"error\";\n\nexport type OTPFieldProps = Omit<React.ComponentProps<\"div\">, \"children\" | \"defaultValue\" | \"onChange\"> & {\n  /** Number of cells. */\n  length?: number;\n  /** Put a separator after every n cells, e.g. 3 for 123–456. 0 for none. */\n  groupSize?: number;\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /** Called once every cell is filled, by typing, paste or autofill. */\n  onValueComplete?: (value: string) => void;\n  /**\n   * Drive the result from outside. `verifying` locks the cells and ripples them,\n   * `success` tints them in sequence, `error` turns them danger and then clears\n   * them after `errorClearDelay` so the next attempt starts fresh.\n   */\n  status?: OTPStatus;\n  /** Milliseconds the wrong code stays on screen before the cells clear. */\n  errorClearDelay?: number;\n  label?: React.ReactNode;\n  description?: React.ReactNode;\n  /** Shown in place of the description while status is error. */\n  error?: React.ReactNode;\n  validationType?: \"numeric\" | \"alpha\" | \"alphanumeric\";\n  /** Hide the characters as dots. */\n  mask?: boolean;\n  size?: \"sm\" | \"md\";\n  name?: string;\n  disabled?: boolean;\n  /** Submit the owning form when complete. */\n  autoSubmit?: boolean;\n  autoFocus?: boolean;\n};\n\nexport function OTPField({\n  length = 6,\n  groupSize = 3,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  onValueComplete,\n  status = \"idle\",\n  errorClearDelay = 1100,\n  label,\n  description,\n  error,\n  validationType = \"numeric\",\n  mask = false,\n  size = \"md\",\n  name,\n  disabled,\n  autoSubmit,\n  autoFocus,\n  className,\n  id: idProp,\n  ...rest\n}: OTPFieldProps) {\n  const reduce = useReducedMotion();\n  const autoId = useId();\n  const id = idProp ?? `otp-${autoId}`;\n  const descId = `${id}-desc`;\n  const rootRef = useRef<HTMLDivElement>(null);\n  const [value, setValue] = useControllableState({ value: valueProp, defaultValue, onChange: onValueChange });\n  const [active, setActive] = useState<number | null>(null);\n\n  // A change that adds several characters at once is a paste or an autofill:\n  // those digits land left to right instead of all on the same frame.\n  const [prev, setPrev] = useState(value);\n  const [batchFrom, setBatchFrom] = useState(-1);\n  if (prev !== value) {\n    setPrev(value);\n    setBatchFrom(value.length - prev.length > 1 ? commonPrefix(prev, value) : -1);\n  }\n\n  const locked = status === \"verifying\" || status === \"success\";\n  const showError = status === \"error\" && value.length === length;\n\n  // After a wrong code: hold it long enough to be seen, then clear and start over at the first cell.\n  const clear = useEffectEvent(() => {\n    setValue(\"\");\n    const first = rootRef.current?.querySelector<HTMLInputElement>(\"input:not([type=hidden])\");\n    if (first && rootRef.current?.contains(document.activeElement)) first.focus();\n  });\n  useEffect(() => {\n    if (status !== \"error\") return;\n    const t = window.setTimeout(clear, errorClearDelay);\n    return () => window.clearTimeout(t);\n  }, [status, errorClearDelay]);\n\n  const tone = status === \"success\" ? \"success\" : showError ? \"error\" : \"idle\";\n  const chars = Array.from({ length }, (_, i) => value[i] ?? \"\");\n  const cell = size === \"sm\" ? \"h-9 w-full rounded-md text-[16px]\" : \"h-11 w-full rounded-lg text-[18px]\";\n  // Cells keep their size until the row runs out of room, then shrink together.\n  const slot = size === \"sm\" ? \"w-8 min-w-6\" : \"w-10 min-w-7\";\n  const noun = validationType === \"numeric\" ? \"Digit\" : \"Character\";\n  const message = status === \"error\" && error ? error : description;\n\n  return (\n    <div\n      data-status={status}\n      data-size={size}\n      className={cn(\"group/otp flex w-fit max-w-full flex-col gap-2\", className)}\n      {...rest}\n    >\n      {label && (\n        <label htmlFor={id} className=\"w-fit text-[12.5px] font-medium leading-4 text-fg\">\n          {label}\n        </label>\n      )}\n      <Base.Root\n        ref={rootRef}\n        id={id}\n        length={length}\n        name={name}\n        value={value}\n        onValueChange={(v) => setValue(v)}\n        onValueComplete={(v) => onValueComplete?.(v)}\n        validationType={validationType}\n        mask={mask}\n        disabled={disabled}\n        readOnly={locked}\n        autoSubmit={autoSubmit}\n        aria-describedby={message ? descId : undefined}\n        className=\"flex max-w-full items-center gap-1.5 data-[disabled]:opacity-50\"\n      >\n        {chars.map((char, i) => {\n          const filled = char !== \"\";\n          const isActive = active === i;\n          return (\n            <Fragment key={i}>\n              {groupSize > 0 && i > 0 && i % groupSize === 0 && (\n                <Base.Separator className=\"mx-1 h-px w-2.5 shrink-0 rounded-full bg-fg-4\" />\n              )}\n              <motion.div\n                className={cn(\"relative shrink\", slot)}\n                initial={false}\n                // Success runs through the cells like a wave, once.\n                animate={status === \"success\" && !reduce ? { scale: [1, 1.08, 1] } : { scale: 1 }}\n                transition={{ duration: 0.34, ease: ease.out, delay: i * 0.05 }}\n              >\n                <Base.Input\n                  autoFocus={autoFocus && i === 0}\n                  aria-label={i === 0 ? undefined : `${noun} ${i + 1} of ${length}`}\n                  onFocus={() => setActive(i)}\n                  onBlur={() => setActive((a) => (a === i ? null : a))}\n                  style={{ \"--i\": i } as React.CSSProperties}\n                  className={cn(\n                    \"block border bg-raised text-center text-transparent caret-transparent outline-none selection:bg-transparent! selection:text-transparent!\",\n                    \"transition-[border-color,background-color,box-shadow] duration-150 ease-out\",\n                    \"focus:ring-3\",\n                    tone === \"idle\" && cn(\"hover:border-fg-4 focus:border-fg-3 focus:ring-fg/8\", filled ? \"border-fg-4/80\" : \"border-line-2\"),\n                    // Verdict colors step through the cells with the wave.\n                    tone === \"success\" && \"border-success/50 bg-success-soft focus:border-success/60 focus:ring-success/15 [transition-delay:calc(var(--i)*50ms)] motion-reduce:[transition-delay:0ms]\",\n                    tone === \"error\" && \"border-danger/60 bg-danger-soft focus:border-danger/80 focus:ring-danger/15\",\n                    \"read-only:cursor-default disabled:cursor-not-allowed\",\n                    cell,\n                  )}\n                />\n                {/* What you see is drawn over the input, so each character can arrive with its own motion. */}\n                <span\n                  aria-hidden\n                  className={cn(\n                    \"pointer-events-none absolute inset-0 grid place-items-center font-medium tabular transition-colors duration-150\",\n                    tone === \"idle\" && \"text-fg\",\n                    tone === \"success\" && \"text-success [transition-delay:calc(var(--i)*50ms)] motion-reduce:[transition-delay:0ms]\",\n                    tone === \"error\" && \"text-danger\",\n                    size === \"sm\" ? \"text-[15px]\" : \"text-[18px]\",\n                  )}\n                  style={{ \"--i\": i } as React.CSSProperties}\n                >\n                  <AnimatePresence initial={false}>\n                    {filled && (\n                      <motion.span\n                        key={`${i}-${char}`}\n                        className=\"col-start-1 row-start-1\"\n                        initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.8, filter: \"blur(2px)\" }}\n                        animate={\n                          status === \"verifying\" && !reduce\n                            ? { opacity: [1, 0.4, 1], y: 0, scale: 1, filter: \"blur(0px)\" }\n                            : { opacity: 1, y: 0, scale: 1, filter: \"blur(0px)\" }\n                        }\n                        exit={reduce ? { opacity: 0, transition: { duration: 0.08 } } : { opacity: 0, scale: 0.7, filter: \"blur(2px)\", transition: { duration: 0.1, ease: ease.in } }}\n                        transition={\n                          status === \"verifying\" && !reduce\n                            ? { opacity: { duration: 1.1, repeat: Infinity, ease: \"easeInOut\", delay: i * 0.09 } }\n                            : reduce\n                              ? { duration: 0.12 }\n                              : { ...spring.pop, delay: batchFrom >= 0 && i >= batchFrom ? (i - batchFrom) * 0.035 : 0 }\n                        }\n                      >\n                        {mask ? <span className=\"block size-2 rounded-full bg-current\" /> : char}\n                      </motion.span>\n                    )}\n                  </AnimatePresence>\n                  {isActive && !filled && !locked && (\n                    <span className={cn(\"col-start-1 row-start-1 w-[1.5px] animate-caret rounded-full bg-fg motion-reduce:animate-none\", size === \"sm\" ? \"h-4\" : \"h-5\")} />\n                  )}\n                </span>\n              </motion.div>\n            </Fragment>\n          );\n        })}\n      </Base.Root>\n\n      {message && (\n        <p id={descId} className={cn(\"flex items-start gap-1.5 text-[12px] leading-4\", status === \"error\" && error ? \"text-danger\" : \"text-fg-3\")}>\n          {status === \"error\" && error && <Alert size={14} className=\"mt-px shrink-0\" />}\n          <span className=\"min-w-0\">{message}</span>\n        </p>\n      )}\n      <span role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {status === \"verifying\" ? \"Verifying code\" : status === \"success\" ? \"Code verified\" : status === \"error\" ? (typeof error === \"string\" ? error : \"That code didn’t work\") : \"\"}\n      </span>\n    </div>\n  );\n}\n\nfunction commonPrefix(a: string, b: string) {\n  let i = 0;\n  while (i < a.length && a[i] === b[i]) i++;\n  return i;\n}\n",
      "type": "registry:ui"
    }
  ],
  "docs": "https://www.stealth.pm/ui/otp-field"
}
