{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "otp-field",
  "title": "OTP Field",
  "description": "A one-time-code input on Base UI's OTPField: autofill and paste fill every cell, the caret blinks in the empty cell, characters pop in, a wrong code shakes once and an accepted one ripples green.",
  "dependencies": [
    "@base-ui/react",
    "motion"
  ],
  "registryDependencies": [
    "https://eased.dev/r/eased-tokens.json",
    "https://eased.dev/r/eased-motion.json",
    "https://eased.dev/r/eased-field.json"
  ],
  "files": [
    {
      "path": "src/registry/eased/otp-field/otp-field.tsx",
      "content": "\"use client\";\n\nimport { OTPField as BaseOTPField } from \"@base-ui/react/otp-field\";\nimport { AnimatePresence, animate, motion } from \"motion/react\";\nimport { Fragment, useEffect, useLayoutEffect, useRef, useState, type ComponentProps } from \"react\";\nimport { useShakeOnInvalid } from \"../_primitives/field/field\";\nimport { bouncy, ease, enter, exitFast, reducedFade, snappy, stagger, useSafeReducedMotion } from \"../lib/motion\";\n\ntype RootProps = ComponentProps<typeof BaseOTPField.Root>;\n\nexport type OtpFieldSize = \"md\" | \"lg\";\n\nexport type OtpFieldProps = Omit<RootProps, \"className\" | \"render\" | \"children\" | \"length\" | \"value\" | \"defaultValue\" | \"onValueChange\"> & {\n  /** Number of characters. Default 6. */\n  length?: number;\n  /**\n   * Cells per group, separated by a dash: `[3, 3]` for 123–456. Default: two halves for an even\n   * length of 6 or more, else one group. Pass `[length]` for no separator.\n   */\n  groups?: readonly number[];\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (value: string) => void;\n  /**\n   * Mark the code wrong: danger borders and one shake when it turns on. Inside a `Field`, the\n   * field's validity drives it too.\n   */\n  invalid?: boolean;\n  /** Mark the code accepted: success borders and a ripple across the cells when it turns on. */\n  success?: boolean;\n  /** md 40×44 cells (default), lg 48×56. */\n  size?: OtpFieldSize;\n  /** Accessible name for slots after the first, given its position. Default \"Character 2 of 6\". */\n  getSlotLabel?: (index: number, length: number) => string;\n  className?: string;\n};\n\nconst CELL: Record<OtpFieldSize, string> = {\n  md: \"h-11 w-10 rounded-control text-ui-lg\",\n  lg: \"h-14 w-12 rounded-control text-display-xl\",\n};\n\nfunction defaultGroups(length: number): number[] {\n  if (length >= 6 && length % 2 === 0) return [length / 2, length / 2];\n  return [length];\n}\n\n/**\n * A one-time-code input on Base UI's OTPField: one input per cell, with `autocomplete=\"one-time-code\"`\n * on the first, so an SMS or email autofill, a password manager or a paste fills every cell at\n * once. The caret blinks in the empty cell that has focus, each character pops in, a wrong code\n * shakes the row once, and an accepted one ripples green across it.\n */\nexport function OtpField({\n  length = 6,\n  groups: groupsProp,\n  value: valueProp,\n  defaultValue = \"\",\n  onValueChange,\n  invalid: invalidProp,\n  success = false,\n  size = \"md\",\n  mask = false,\n  disabled,\n  getSlotLabel = (i, n) => `Character ${i + 1} of ${n}`,\n  className = \"\",\n  ...props\n}: OtpFieldProps) {\n  const reduced = useSafeReducedMotion();\n  const [uncontrolled, setUncontrolled] = useState(defaultValue);\n  const value = valueProp ?? uncontrolled;\n  const [focusIndex, setFocusIndex] = useState<number | null>(null);\n\n  const rowRef = useRef<HTMLDivElement>(null);\n  const fieldInvalid = useDataInvalid(rowRef);\n  const invalid = !disabled && (invalidProp ?? fieldInvalid);\n  useShakeOnInvalid(rowRef, invalid, reduced);\n  useRipple(rowRef, success && !invalid, reduced);\n\n  const groups = groupsProp?.reduce((a, b) => a + b, 0) === length ? groupsProp : defaultGroups(length);\n  const starts = groups.map((_, g) => groups.slice(0, g).reduce((a, b) => a + b, 0));\n  const tone = invalid ? \"invalid\" : success ? \"success\" : \"idle\";\n\n  return (\n    <BaseOTPField.Root\n      {...props}\n      ref={rowRef}\n      length={length}\n      value={value}\n      mask={mask}\n      disabled={disabled}\n      onValueChange={(next) => {\n        if (valueProp === undefined) setUncontrolled(next);\n        onValueChange?.(next);\n      }}\n      onBlur={(e) => {\n        if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setFocusIndex(null);\n      }}\n      className={`flex w-fit max-w-full items-center gap-2 ${className}`}\n    >\n      {groups.map((count, g) => (\n        <Fragment key={g}>\n          {g > 0 && (\n            <BaseOTPField.Separator className=\"flex w-2 shrink-0 items-center justify-center\" render={<span />}>\n              <span className={`h-0.5 w-2 rounded-pill ${disabled ? \"bg-text-disabled\" : \"bg-line-strong\"}`} />\n            </BaseOTPField.Separator>\n          )}\n          <div className=\"flex items-center gap-1.5\">\n            {Array.from({ length: count }, (_, k) => {\n              const i = starts[g] + k;\n              const char = value[i] ?? \"\";\n              const focused = focusIndex === i;\n              return (\n                <div\n                  key={i}\n                  data-otp-cell=\"\"\n                  data-tone={tone}\n                  data-focused={focused || undefined}\n                  data-filled={char ? \"\" : undefined}\n                  className={`relative flex shrink-0 items-center justify-center border font-medium tabular-nums ${disabled ? \"bg-surface-sunken\" : \"bg-surface\"} transition-[border-color,box-shadow,color,background-color] duration-(--eased-duration-standard) ease-standard reduced:transition-none ${CELL[size]} ${\n                    disabled\n                      ? \"cursor-not-allowed border-line text-text-disabled\"\n                      : tone === \"invalid\"\n                        ? `border-danger text-text ${focused ? \"shadow-[0_0_0_3px_var(--eased-ring-danger)]\" : \"\"}`\n                        : tone === \"success\"\n                          ? \"border-success text-success-text\"\n                          : focused\n                            ? \"border-(--eased-neutral-9) text-text shadow-[0_0_0_3px_var(--eased-ring-soft)]\"\n                            : \"border-line-strong text-text hover-fine:border-(--eased-neutral-9)\"\n                  }`}\n                >\n                  <BaseOTPField.Input\n                    // Base UI names the first slot from the field's label; the rest by position.\n                    aria-label={i > 0 ? getSlotLabel(i, length) : undefined}\n                    {...(invalidProp !== undefined ? { \"aria-invalid\": invalidProp || undefined } : null)}\n                    onFocus={() => setFocusIndex(i)}\n                    className=\"absolute inset-0 size-full cursor-text rounded-[inherit] bg-transparent text-center text-transparent caret-transparent outline-none selection:bg-transparent disabled:cursor-not-allowed\"\n                  />\n                  <span aria-hidden className=\"pointer-events-none relative flex size-full items-center justify-center\">\n                    <AnimatePresence initial={false} mode=\"popLayout\">\n                      {char ? (\n                        <motion.span\n                          key={`${i}:${char}`}\n                          initial={reduced ? { opacity: 0 } : { opacity: 0, scale: 0.5, y: 3, filter: \"blur(2px)\" }}\n                          animate={{ opacity: 1, scale: 1, y: 0, filter: \"blur(0px)\" }}\n                          exit={reduced ? { opacity: 0, transition: reducedFade } : { opacity: 0, scale: 0.7, filter: \"blur(2px)\", transition: exitFast }}\n                          transition={reduced ? reducedFade : { scale: bouncy, y: snappy, opacity: enter, filter: enter }}\n                          className=\"inline-block\"\n                        >\n                          {mask ? \"•\" : char}\n                        </motion.span>\n                      ) : focused && !disabled ? (\n                        <Caret key=\"caret\" reduced={reduced} size={size} />\n                      ) : null}\n                    </AnimatePresence>\n                  </span>\n                </div>\n              );\n            })}\n          </div>\n        </Fragment>\n      ))}\n    </BaseOTPField.Root>\n  );\n}\n\n/** A 1.5px caret that holds, fades out, holds and fades back in, like a native text field. Static under reduced motion. */\nfunction Caret({ reduced, size, ref }: { reduced: boolean; size: OtpFieldSize; ref?: React.Ref<HTMLSpanElement> }) {\n  return (\n    <motion.span\n      ref={ref}\n      initial={{ opacity: 0 }}\n      animate={reduced ? { opacity: 1 } : { opacity: [1, 1, 0, 0, 1] }}\n      exit={{ opacity: 0, transition: { duration: 0 } }}\n      transition={reduced ? reducedFade : { duration: 1.1, times: [0, 0.45, 0.55, 0.9, 1], ease: \"linear\", repeat: Infinity }}\n      className={`block w-[1.5px] rounded-pill bg-text ${size === \"lg\" ? \"h-7\" : \"h-5\"}`}\n    />\n  );\n}\n\n/** Whether Base UI marked the field invalid (a `Field`'s validation or `<Form errors>`). */\nfunction useDataInvalid(ref: React.RefObject<HTMLElement | null>) {\n  const [invalid, setInvalid] = useState(false);\n  useLayoutEffect(() => {\n    const el = ref.current;\n    if (!el) return;\n    const read = () => setInvalid(el.hasAttribute(\"data-invalid\"));\n    read();\n    const mo = new MutationObserver(read);\n    mo.observe(el, { attributes: true, attributeFilter: [\"data-invalid\"] });\n    return () => mo.disconnect();\n  }, [ref]);\n  return invalid;\n}\n\n/** A left-to-right lift across the cells when `on` turns on: 2px up and back, 35ms apart. Reduced motion: none. */\nfunction useRipple(ref: React.RefObject<HTMLElement | null>, on: boolean, reduced: boolean) {\n  const was = useRef(on);\n  useEffect(() => {\n    const turnedOn = on && !was.current;\n    was.current = on;\n    if (!turnedOn || reduced || !ref.current) return;\n    const cells = ref.current.querySelectorAll<HTMLElement>(\"[data-otp-cell]\");\n    const runs = [...cells].map((cell, i) =>\n      animate(cell, { y: [0, -3, 0], scale: [1, 1.04, 1] }, { duration: 0.32, delay: i * stagger.item, ease: ease.standard }),\n    );\n    return () => runs.forEach((r) => r.stop());\n  }, [on, reduced, ref]);\n}\n",
      "type": "registry:ui",
      "target": "@components/eased/otp-field/otp-field.tsx"
    }
  ],
  "docs": "Import OtpField from components/eased/otp-field/otp-field; wrap it in Field for a label and messages. The Eased tokens were merged into your global CSS; nothing to import.",
  "type": "registry:ui"
}