{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "input-otp",
  "type": "registry:ui",
  "title": "Input OTP",
  "description": "An accessible React one-time code input for Next.js and Tailwind: boxes that move on as you type and take a pasted code, built on Radix.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "lucide-react@^1.52.0",
    "motion@^14.0.0",
    "radix-ui@^1.7.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/input-otp.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { duration, ease, spring, swap } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { MinusIcon } from \"lucide-react\";\nimport { AnimatePresence, animate, LayoutGroup, motion, useReducedMotion } from \"motion/react\";\nimport { unstable_OneTimePasswordField as OneTimePasswordField } from \"radix-ui\";\nimport {\n  type ComponentProps,\n  createContext,\n  useContext,\n  useEffect,\n  useId,\n  useLayoutEffect,\n  useRef,\n  useState,\n} from \"react\";\n\n/*\n * Input OTP: one-time code boxes on Radix One-Time Password Field (owner, 2026-10-16). Radix types each\n * character into its own input and moves on, goes back with Backspace, spreads a pasted or autofilled code\n * across the boxes, and submits the code through a hidden input.\n * - Motion (.agents/rules/motion.md):\n *   - One focus box per field glides to the next box as you type (layoutId on spring.snappy, scoped to the\n *     field with LayoutGroup id={useId()}), so the eye follows where the next character goes.\n *   - A typed character rises 6px into its box through the blur (the library's `swap`). Typed over, the old\n *     one lifts away as the new rises; deleted, it sinks back the way it came.\n *   - A pasted code comes in left to right, staggered by 0.05s like items in a list.\n *   - The empty focused box shows a soft caret that breathes rather than blinks. The native caret and text\n *     are transparent; what you see is drawn over the input.\n *   - status=\"error\": the field shakes its head, a quick side-to-side that dies away, while every border\n *     and the focus box turn destructive (owner, 2026-10-21). It shakes each time the status becomes error.\n *   - status=\"success\": a wave runs left to right, each box swelling 6% and settling again (duration.slow,\n *     ease.out), 0.05s apart, as its border turns solid. Neutral, like Loading Button's success.\n * - Nothing animates on first paint. With reduced motion the characters only fade, the box jumps, the\n *   caret stays still, and success and error only change colour.\n * - Boxes match Input: control heights, Input's border and focus ring, a destructive border when invalid.\n */\n\ntype Size = \"sm\" | \"md\" | \"lg\";\nexport type InputOTPStatus = \"success\" | \"error\";\n\n/** Items in a list come in 0.05s apart (motion.md); a pasted code's characters do the same */\nconst STAGGER = 0.05;\n/** The head shake, in px: out to one side and back, smaller each time */\nconst SHAKE = [0, -8, 7, -5, 3, -1, 0];\n/** How much a box swells as the success wave passes */\nconst SWELL = 1.06;\n/** Keys that move between boxes, which bring the focus box back on a full code */\nconst MOVES = new Set([\"ArrowLeft\", \"ArrowRight\", \"ArrowUp\", \"ArrowDown\", \"Home\", \"End\"]);\n\ntype FieldContextValue = {\n  value: string;\n  /** The box that has focus, which the focus box sits on */\n  active: number | null;\n  setActive: (index: number | null) => void;\n  size: Size;\n  password: boolean;\n  invalid: boolean;\n  success: boolean;\n  disabled: boolean;\n  reduce: boolean;\n  /** Several characters arrived at once (a paste or autofill): where they start, so they come in in turn */\n  batchFrom: number | null;\n  /** Every box is filled and you haven't moved since: the focus box fades away */\n  focusHidden: boolean;\n  /** Brings the focus box back */\n  reveal: () => void;\n};\n\nconst FieldContext = createContext<FieldContextValue | null>(null);\n\nconst slotSizes = {\n  sm: \"size-(--size-control-sm) rounded-md text-sm\",\n  md: \"size-(--size-control-md) rounded-lg text-base\",\n  lg: \"size-(--size-control-lg) rounded-lg text-base\",\n} as const;\nconst indicatorRadius = { sm: \"rounded-md\", md: \"rounded-lg\", lg: \"rounded-lg\" } as const;\nconst caretHeights = { sm: \"h-3.5\", md: \"h-4\", lg: \"h-5\" } as const;\nconst dotSizes = { sm: \"size-1.5\", md: \"size-2\", lg: \"size-2\" } as const;\n\nexport type InputOTPProps = Omit<ComponentProps<typeof OneTimePasswordField.Root>, \"size\"> & {\n  /** The boxes' size, matching Input's heights */\n  size?: Size;\n  /**\n   * The result of checking the code: success runs a wave along the boxes, error shakes the field and marks\n   * it invalid. Leave it unset while the code is being typed or checked.\n   */\n  status?: InputOTPStatus;\n};\n\n/**\n * A one-time code field: put InputOTPSlot boxes (in InputOTPGroups, with an InputOTPSeparator between them)\n * inside. The number of slots is the code's length. Give it a name with aria-label or aria-labelledby, and\n * set aria-invalid when the code is wrong.\n */\nfunction InputOTP({\n  className,\n  children,\n  size = \"md\",\n  value,\n  defaultValue,\n  onValueChange,\n  type,\n  disabled = false,\n  status,\n  \"aria-invalid\": ariaInvalid,\n  ref,\n  onKeyDown,\n  onPointerDown,\n  ...props\n}: InputOTPProps) {\n  // Mirror the value, controlled or not, so each box can draw its own character\n  const [uncontrolled, setUncontrolled] = useState(defaultValue ?? \"\");\n  const current = value ?? uncontrolled;\n  const [active, setActive] = useState<number | null>(null);\n  const reduce = useReducedMotion() ?? false;\n  const groupId = useId();\n  const invalid = ariaInvalid === true || ariaInvalid === \"true\" || status === \"error\";\n  const root = useRef<HTMLDivElement | null>(null);\n\n  // The head shake, each time the status becomes error (never on first paint, nor with reduced motion)\n  const lastStatus = useRef(status);\n  useEffect(() => {\n    const before = lastStatus.current;\n    lastStatus.current = status;\n    const node = root.current;\n    if (status !== \"error\" || before === \"error\" || reduce || !node) return;\n    const shake = animate(node, { x: SHAKE }, { duration: duration.slower, ease: ease.out });\n    shake.then(() => node.style.removeProperty(\"transform\"));\n    return () => {\n      shake.stop();\n      node.style.removeProperty(\"transform\");\n    };\n  }, [status, reduce]);\n\n  // Once every box is filled the focus box fades away: the code is done, and the boxes read as a whole. It\n  // comes back the moment you move (arrow keys, Home, End, a click), arrive from outside, or change a digit,\n  // so the keyboard is never left without a visible focus.\n  const [slots, setSlots] = useState(0);\n  const [revealed, setRevealed] = useState(false);\n  useLayoutEffect(() => {\n    const count = root.current?.querySelectorAll(\"[data-slot=input-otp-slot]\").length ?? 0;\n    if (count !== slots) setSlots(count);\n  });\n  const focusHidden = slots > 0 && current.length >= slots && !revealed;\n\n  // Which characters just changed. More than one at once is a paste or autofill, so they come in in turn.\n  const [seen, setSeen] = useState<{ value: string; batchFrom: number | null }>({\n    value: current,\n    batchFrom: null,\n  });\n  let batchFrom = seen.batchFrom;\n  if (seen.value !== current) {\n    const changed: number[] = [];\n    for (let index = 0; index < current.length; index++) {\n      if (current[index] !== seen.value[index]) changed.push(index);\n    }\n    batchFrom = changed.length > 1 ? (changed[0] ?? null) : null;\n    setSeen({ value: current, batchFrom });\n    setRevealed(false);\n  }\n\n  return (\n    <FieldContext.Provider\n      value={{\n        value: current,\n        active,\n        setActive,\n        size,\n        password: type === \"password\",\n        invalid,\n        success: status === \"success\",\n        disabled,\n        reduce,\n        batchFrom,\n        focusHidden,\n        reveal: () => setRevealed(true),\n      }}\n    >\n      <OneTimePasswordField.Root\n        ref={(node) => {\n          root.current = node;\n          if (typeof ref === \"function\") ref(node);\n          else if (ref) ref.current = node;\n        }}\n        data-slot=\"input-otp\"\n        data-status={status}\n        value={current}\n        onValueChange={(next) => {\n          if (value === undefined) setUncontrolled(next);\n          onValueChange?.(next);\n        }}\n        type={type}\n        disabled={disabled}\n        onKeyDown={(event) => {\n          if (MOVES.has(event.key)) setRevealed(true);\n          onKeyDown?.(event);\n        }}\n        onPointerDown={(event) => {\n          setRevealed(true);\n          onPointerDown?.(event);\n        }}\n        className={cn(\"flex items-center gap-2\", className)}\n        {...props}\n      >\n        <LayoutGroup id={groupId}>{children}</LayoutGroup>\n        <OneTimePasswordField.HiddenInput />\n      </OneTimePasswordField.Root>\n    </FieldContext.Provider>\n  );\n}\n\n/** Boxes that sit together, such as the first three digits of a six-digit code */\nfunction InputOTPGroup({ className, ...props }: ComponentProps<\"div\">) {\n  return <div data-slot=\"input-otp-group\" className={cn(\"flex items-center gap-2\", className)} {...props} />;\n}\n\nexport type InputOTPSlotProps = ComponentProps<typeof OneTimePasswordField.Input> & {\n  /** The box's place in the code, from 0 */\n  index: number;\n};\n\n/**\n * One box. `className` styles the box; the other props go to its input. Radix names each input\n * \"Character 1 of 6\" and so on; pass aria-label to change it.\n */\nfunction InputOTPSlot({ index, className, onFocus, onBlur, ...props }: InputOTPSlotProps) {\n  const field = useContext(FieldContext);\n  const size = field?.size ?? \"md\";\n  const reduce = field?.reduce ?? false;\n  const char = field?.value[index] ?? \"\";\n  const active = field?.active === index;\n  const delay =\n    field?.batchFrom != null && index >= field.batchFrom ? (index - field.batchFrom) * STAGGER : 0;\n\n  return (\n    <motion.div\n      data-slot=\"input-otp-slot\"\n      data-active={active || undefined}\n      data-filled={char ? \"\" : undefined}\n      data-invalid={field?.invalid || undefined}\n      data-success={field?.success || undefined}\n      data-disabled={field?.disabled || undefined}\n      // The success wave: each box swells and settles in turn. A named state, so it plays once per success.\n      initial={false}\n      animate={field?.success && !reduce ? \"success\" : \"rest\"}\n      variants={{\n        rest: { scale: 1 },\n        success: {\n          scale: [1, SWELL, 1],\n          transition: { duration: duration.slow, ease: ease.out, delay: index * STAGGER },\n        },\n      }}\n      className={cn(\n        \"relative grid shrink-0 place-items-center border border-input bg-transparent font-medium text-foreground tabular-nums shadow-xs\",\n        \"transition-colors duration-(--duration-base) ease-out data-invalid:border-destructive data-success:border-primary\",\n        \"data-disabled:cursor-not-allowed data-disabled:opacity-(--opacity-disabled)\",\n        slotSizes[size],\n        className,\n      )}\n    >\n      {/* The real input fills the box. Its text, caret and selection are transparent: the character, the caret\n          and the focus box are drawn over it, so they can move */}\n      <OneTimePasswordField.Input\n        index={index}\n        data-slot=\"input-otp-input\"\n        aria-invalid={field?.invalid || undefined}\n        onFocus={(event) => {\n          field?.setActive(index);\n          // Arriving from outside the field (Tab), the focus box shows even on a full code\n          const from = event.relatedTarget;\n          const root = event.currentTarget.closest(\"[data-slot=input-otp]\");\n          if (!(from instanceof Node && root?.contains(from))) field?.reveal();\n          onFocus?.(event);\n        }}\n        onBlur={(event) => {\n          // Moving to another box keeps the focus box (it glides there); leaving the field clears it\n          const next = event.relatedTarget;\n          const root = event.currentTarget.closest(\"[data-slot=input-otp]\");\n          if (!(next instanceof Node && root?.contains(next))) field?.setActive(null);\n          onBlur?.(event);\n        }}\n        className=\"absolute inset-0 size-full cursor-text bg-transparent text-center text-transparent caret-transparent outline-hidden selection:bg-transparent placeholder:text-muted-foreground disabled:cursor-not-allowed\"\n        {...props}\n      />\n\n      {/* Deleted, the character sinks back the way it came; typed over, it lifts away as the new one rises */}\n      <AnimatePresence initial={false} custom={char ? \"up\" : \"down\"}>\n        {char ? (\n          <motion.span\n            key={char}\n            aria-hidden\n            data-slot=\"input-otp-char\"\n            className=\"pointer-events-none col-start-1 row-start-1\"\n            initial=\"hidden\"\n            animate=\"shown\"\n            exit=\"gone\"\n            variants={{\n              hidden: reduce ? { opacity: 0 } : swap.initial,\n              shown: {\n                ...(reduce ? { opacity: 1 } : swap.animate),\n                transition: reduce\n                  ? { duration: duration.fast, delay }\n                  : { duration: duration.base, ease: ease.out, delay, y: { ...spring.snappy, delay } },\n              },\n              gone: (leave: \"up\" | \"down\") => ({\n                ...(reduce ? { opacity: 0 } : leave === \"down\" ? swap.initial : swap.exit),\n                transition: reduce\n                  ? { duration: duration.fast }\n                  : { duration: duration.base, ease: ease.out, y: spring.snappy },\n              }),\n            }}\n          >\n            {field?.password ? (\n              <span className={cn(\"block rounded-full bg-current\", dotSizes[size])} />\n            ) : (\n              char\n            )}\n          </motion.span>\n        ) : null}\n      </AnimatePresence>\n\n      {active && !char ? (\n        <motion.span\n          aria-hidden\n          data-slot=\"input-otp-caret\"\n          className={cn(\n            \"pointer-events-none col-start-1 row-start-1 w-px rounded-full bg-foreground\",\n            caretHeights[size],\n          )}\n          // A soft caret: it breathes once a second instead of blinking hard. Still with reduced motion.\n          animate={reduce ? { opacity: 1 } : { opacity: [1, 0, 1] }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : { duration: duration.slower * 2, ease: ease.inOut, repeat: Number.POSITIVE_INFINITY }\n          }\n        />\n      ) : null}\n\n      {active ? (\n        // One focus box per field: Input's focus border and ring, gliding from box to box\n        <motion.span\n          layoutId=\"input-otp-indicator\"\n          aria-hidden\n          data-slot=\"input-otp-indicator\"\n          data-hidden={field?.focusHidden || undefined}\n          className={cn(\n            \"pointer-events-none absolute -inset-px border border-ring outline-(length:--focus-ring-width) outline-offset-(--focus-ring-offset) outline-ring\",\n            field?.invalid && \"border-destructive outline-destructive\",\n            indicatorRadius[size],\n          )}\n          // A ring, so it only fades as the code fills and comes back when you move\n          initial={false}\n          animate={{ opacity: field?.focusHidden ? 0 : 1 }}\n          transition={\n            reduce\n              ? { duration: 0 }\n              : { ...spring.snappy, opacity: { duration: duration.base, ease: ease.out } }\n          }\n        />\n      ) : null}\n    </motion.div>\n  );\n}\n\n/** A dash between groups of boxes. Pass children to draw something else. */\nfunction InputOTPSeparator({ className, children, ...props }: ComponentProps<\"div\">) {\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: it holds an icon, which an hr can't; shadcn's has the same role\n    <div\n      data-slot=\"input-otp-separator\"\n      role=\"separator\"\n      className={cn(\"flex items-center text-muted-foreground\", className)}\n      {...props}\n    >\n      {children ?? <MinusIcon aria-hidden className=\"size-(--size-icon-md)\" />}\n    </div>\n  );\n}\n\nexport { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot };\n"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/input-otp"
}
