{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion","lucide-react"],"description":"A numeric field with rolling digits, keyboard stepping and horizontal scrub-to-change.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport SmoothButton from \"@/components/smoothui/smooth-button\";\nimport { Minus, Plus } from \"lucide-react\";\nimport {\n  AnimatePresence,\n  motion,\n  useAnimation,\n  useReducedMotion,\n} from \"motion/react\";\nimport {\n  type ChangeEvent,\n  type KeyboardEvent,\n  type PointerEvent as ReactPointerEvent,\n  useCallback,\n  useEffect,\n  useId,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\n\nconst DEFAULT_STEP = 1;\nconst DEFAULT_MIN = Number.NEGATIVE_INFINITY;\nconst DEFAULT_MAX = Number.POSITIVE_INFINITY;\nconst LARGE_STEP_MULTIPLIER = 10;\nconst SMALL_STEP_DIVISOR = 10;\nconst SCRUB_THRESHOLD_PX = 4;\nconst SCRUB_STEP_PX = 8;\nconst ROLL_DISTANCE = 14;\nconst SHAKE_DISTANCE = 6;\nconst SHAKE_DURATION = 0.4;\nconst DIGIT_SPRING = { bounce: 0.1, duration: 0.25, type: \"spring\" as const };\nconst MOVEMENT_EASE: [number, number, number, number] = [\n  0.645, 0.045, 0.355, 1,\n];\nconst NON_NUMERIC_CHARS_REGEX = /[^\\d.-]/g;\n\nexport type AnimatedNumberInputProps = {\n  /** Additional CSS classes for the outer wrapper */\n  className?: string;\n  /** Whether a typed out-of-range value is clamped to min/max on blur */\n  clampOnBlur?: boolean;\n  /** Default value for uncontrolled usage */\n  defaultValue?: number;\n  /** Whether the field is disabled */\n  disabled?: boolean;\n  /** Intl.NumberFormat options used to render the value */\n  format?: Intl.NumberFormatOptions;\n  /** Visible label rendered above the field */\n  label?: string;\n  /** Maximum allowed value */\n  max?: number;\n  /** Minimum allowed value */\n  min?: number;\n  /** Called whenever the committed value changes */\n  onValueChange?: (value: number) => void;\n  /** Number of decimal places the value is rounded to */\n  precision?: number;\n  /** Static text rendered before the value */\n  prefix?: string;\n  /** Whether dragging horizontally changes the value */\n  scrub?: boolean;\n  /** Increment used by arrows, stepper buttons and scrubbing */\n  step?: number;\n  /** Whether to render +/- stepper buttons */\n  stepper?: boolean;\n  /** Static text rendered after the value */\n  suffix?: string;\n  /** Controlled value */\n  value?: number;\n};\n\nconst clampValue = (num: number, min: number, max: number) =>\n  Math.min(max, Math.max(min, num));\n\nconst isDigitChar = (char: string) => /\\d/.test(char);\n\nconst parseRawValue = (\n  text: string,\n  prefix?: string,\n  suffix?: string\n): number | null => {\n  let cleaned = text.trim();\n  if (prefix && cleaned.startsWith(prefix)) {\n    cleaned = cleaned.slice(prefix.length);\n  }\n  if (suffix && cleaned.endsWith(suffix)) {\n    cleaned = cleaned.slice(0, cleaned.length - suffix.length);\n  }\n  cleaned = cleaned.replace(NON_NUMERIC_CHARS_REGEX, \"\");\n  if (cleaned === \"\" || cleaned === \"-\") {\n    return null;\n  }\n  const parsed = Number(cleaned);\n  return Number.isFinite(parsed) ? parsed : null;\n};\n\ntype DigitSlotProps = {\n  char: string;\n  direction: 1 | -1;\n  reduceMotion: boolean;\n};\n\nconst DigitSlot = ({ char, direction, reduceMotion }: DigitSlotProps) => {\n  const rolling = isDigitChar(char);\n  return (\n    <span className=\"relative inline-block h-[1.2em] w-[0.62em] overflow-hidden text-center align-bottom tabular-nums\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ opacity: 1, y: 0 }}\n          className=\"absolute inset-0 flex items-center justify-center\"\n          exit={\n            reduceMotion\n              ? { opacity: 0, transition: { duration: 0 } }\n              : { opacity: 0, y: rolling ? -direction * ROLL_DISTANCE : 0 }\n          }\n          initial={\n            reduceMotion\n              ? { opacity: 1, y: 0 }\n              : { opacity: 0, y: rolling ? direction * ROLL_DISTANCE : 0 }\n          }\n          key={char}\n          transition={reduceMotion ? { duration: 0 } : DIGIT_SPRING}\n        >\n          {char}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n};\n\ntype FadeBlockProps = {\n  className?: string;\n  reduceMotion: boolean;\n  text: string;\n};\n\nconst FadeBlock = ({ text, reduceMotion, className }: FadeBlockProps) => (\n  <AnimatePresence initial={false} mode=\"popLayout\">\n    <motion.span\n      animate={{ opacity: 1 }}\n      className={cn(\"inline-block\", className)}\n      exit={\n        reduceMotion\n          ? { opacity: 0, transition: { duration: 0 } }\n          : { opacity: 0 }\n      }\n      initial={reduceMotion ? { opacity: 1 } : { opacity: 0 }}\n      key={text}\n      transition={reduceMotion ? { duration: 0 } : DIGIT_SPRING}\n    >\n      {text}\n    </motion.span>\n  </AnimatePresence>\n);\n\nconst AnimatedNumberInput = ({\n  value: controlledValue,\n  defaultValue = 0,\n  onValueChange,\n  min = DEFAULT_MIN,\n  max = DEFAULT_MAX,\n  step = DEFAULT_STEP,\n  precision,\n  format,\n  prefix = \"\",\n  suffix = \"\",\n  scrub = true,\n  stepper = false,\n  clampOnBlur = true,\n  disabled = false,\n  label,\n  className,\n}: AnimatedNumberInputProps) => {\n  const shouldReduceMotion = !!useReducedMotion();\n  const reactId = useId();\n  const inputId = `animated-number-input-${reactId.replace(/:/g, \"\")}`;\n\n  const [internalValue, setInternalValue] = useState(defaultValue);\n  const isControlled = controlledValue !== undefined;\n  const value = isControlled ? controlledValue : internalValue;\n  const lastValueRef = useRef(value);\n\n  const [isEditing, setIsEditing] = useState(false);\n  const [rawInputValue, setRawInputValue] = useState(\"\");\n  const [direction, setDirection] = useState<1 | -1>(1);\n  const [isInvalid, setIsInvalid] = useState(false);\n  const [isScrubbing, setIsScrubbing] = useState(false);\n\n  const shakeControls = useAnimation();\n  const scrubRef = useRef<{\n    active: boolean;\n    startValue: number;\n    startX: number;\n  } | null>(null);\n\n  const numberFormatter = useMemo(\n    () =>\n      new Intl.NumberFormat(undefined, {\n        maximumFractionDigits: precision,\n        minimumFractionDigits: precision,\n        ...format,\n      }),\n    [precision, format]\n  );\n\n  const formattedText = useMemo(\n    () => numberFormatter.format(value),\n    [numberFormatter, value]\n  );\n\n  useEffect(() => {\n    lastValueRef.current = value;\n  }, [value]);\n\n  useEffect(() => {\n    if (!isInvalid) {\n      return;\n    }\n    if (shouldReduceMotion) {\n      setIsInvalid(false);\n      return;\n    }\n    let cancelled = false;\n    shakeControls\n      .start({\n        transition: { duration: SHAKE_DURATION, ease: MOVEMENT_EASE },\n        x: [0, -SHAKE_DISTANCE, SHAKE_DISTANCE, -SHAKE_DISTANCE / 2, 0],\n      })\n      .then(() => {\n        if (!cancelled) {\n          setIsInvalid(false);\n        }\n      });\n    return () => {\n      cancelled = true;\n    };\n  }, [isInvalid, shouldReduceMotion, shakeControls]);\n\n  const applyValue = useCallback(\n    (next: number, options?: { clamp?: boolean }) => {\n      const shouldClamp = options?.clamp ?? true;\n      let nextValue = shouldClamp ? clampValue(next, min, max) : next;\n      if (precision !== undefined) {\n        const factor = 10 ** precision;\n        nextValue = Math.round(nextValue * factor) / factor;\n      }\n      setDirection(nextValue >= lastValueRef.current ? 1 : -1);\n      lastValueRef.current = nextValue;\n      if (!isControlled) {\n        setInternalValue(nextValue);\n      }\n      onValueChange?.(nextValue);\n      setIsEditing(false);\n      setIsInvalid(false);\n    },\n    [isControlled, min, max, precision, onValueChange]\n  );\n\n  const commitFromText = useCallback(() => {\n    const parsed = parseRawValue(rawInputValue, prefix, suffix);\n    if (parsed === null) {\n      setIsInvalid(true);\n      setIsEditing(false);\n      setRawInputValue(formattedText);\n      return;\n    }\n    applyValue(parsed, { clamp: clampOnBlur });\n  }, [rawInputValue, prefix, suffix, formattedText, clampOnBlur, applyValue]);\n\n  const handleFocus = () => {\n    setIsEditing(true);\n    setRawInputValue(formattedText);\n  };\n\n  const handleBlur = () => {\n    commitFromText();\n  };\n\n  const handleChange = (event: ChangeEvent<HTMLInputElement>) => {\n    setIsEditing(true);\n    setRawInputValue(event.target.value);\n  };\n\n  const handleKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {\n    if (disabled) {\n      return;\n    }\n    const bigStep = step * LARGE_STEP_MULTIPLIER;\n    const smallStep = step / SMALL_STEP_DIVISOR;\n    const effectiveStep = event.shiftKey\n      ? bigStep\n      : event.altKey\n        ? smallStep\n        : step;\n\n    switch (event.key) {\n      case \"ArrowUp\":\n        event.preventDefault();\n        applyValue(lastValueRef.current + effectiveStep);\n        break;\n      case \"ArrowDown\":\n        event.preventDefault();\n        applyValue(lastValueRef.current - effectiveStep);\n        break;\n      case \"PageUp\":\n        event.preventDefault();\n        applyValue(lastValueRef.current + bigStep);\n        break;\n      case \"PageDown\":\n        event.preventDefault();\n        applyValue(lastValueRef.current - bigStep);\n        break;\n      case \"Home\":\n        if (Number.isFinite(min)) {\n          event.preventDefault();\n          applyValue(min);\n        }\n        break;\n      case \"End\":\n        if (Number.isFinite(max)) {\n          event.preventDefault();\n          applyValue(max);\n        }\n        break;\n      case \"Enter\":\n        event.preventDefault();\n        commitFromText();\n        break;\n      default:\n        break;\n    }\n  };\n\n  const handlePointerDown = (event: ReactPointerEvent<HTMLInputElement>) => {\n    if (!scrub || disabled) {\n      return;\n    }\n    scrubRef.current = {\n      active: false,\n      startValue: lastValueRef.current,\n      startX: event.clientX,\n    };\n  };\n\n  const handlePointerMove = (event: ReactPointerEvent<HTMLInputElement>) => {\n    if (!(scrub && scrubRef.current) || disabled) {\n      return;\n    }\n    const state = scrubRef.current;\n    const deltaX = event.clientX - state.startX;\n    if (!state.active) {\n      if (Math.abs(deltaX) < SCRUB_THRESHOLD_PX) {\n        return;\n      }\n      state.active = true;\n      setIsScrubbing(true);\n      event.currentTarget.setPointerCapture(event.pointerId);\n    }\n    event.preventDefault();\n    const steps = Math.trunc(deltaX / SCRUB_STEP_PX);\n    applyValue(state.startValue + steps * step);\n  };\n\n  const handlePointerUp = (event: ReactPointerEvent<HTMLInputElement>) => {\n    if (scrubRef.current?.active) {\n      event.currentTarget.releasePointerCapture(event.pointerId);\n      setIsScrubbing(false);\n    }\n    scrubRef.current = null;\n  };\n\n  const handleStep = (delta: number) => () => {\n    if (disabled) {\n      return;\n    }\n    applyValue(lastValueRef.current + delta);\n  };\n\n  const currentText = isEditing ? rawInputValue : formattedText;\n\n  return (\n    <div className={cn(\"inline-flex flex-col gap-1.5\", className)}>\n      {label ? (\n        <label\n          className=\"font-medium text-foreground text-sm\"\n          htmlFor={inputId}\n        >\n          {label}\n        </label>\n      ) : null}\n      <motion.div\n        animate={shakeControls}\n        className={cn(\n          \"relative flex h-10 items-center overflow-hidden rounded-lg border border-border bg-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background\",\n          isInvalid && \"border-destructive\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          scrub && !disabled && !isScrubbing && \"cursor-ew-resize\",\n          isScrubbing && \"cursor-ew-resize\"\n        )}\n      >\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none flex flex-1 select-none items-center px-3 font-medium text-foreground text-sm\"\n        >\n          {prefix ? (\n            <FadeBlock\n              className=\"mr-0.5\"\n              reduceMotion={shouldReduceMotion}\n              text={prefix}\n            />\n          ) : null}\n          {currentText.split(\"\").map((char, index) => (\n            <DigitSlot\n              char={char}\n              direction={direction}\n              // biome-ignore lint/suspicious/noArrayIndexKey: fixed-width positional digit slots, identity is carried by the inner AnimatePresence key\n              key={index}\n              reduceMotion={shouldReduceMotion}\n            />\n          ))}\n          {suffix ? (\n            <FadeBlock\n              className=\"ml-0.5\"\n              reduceMotion={shouldReduceMotion}\n              text={suffix}\n            />\n          ) : null}\n        </div>\n        <input\n          aria-invalid={isInvalid}\n          aria-label={label ?? \"Number input\"}\n          aria-valuemax={Number.isFinite(max) ? max : undefined}\n          aria-valuemin={Number.isFinite(min) ? min : undefined}\n          aria-valuenow={value}\n          aria-valuetext={formattedText}\n          className=\"absolute inset-0 h-full w-full bg-transparent px-3 text-transparent outline-none\"\n          disabled={disabled}\n          id={inputId}\n          inputMode=\"decimal\"\n          onBlur={handleBlur}\n          onChange={handleChange}\n          onFocus={handleFocus}\n          onKeyDown={handleKeyDown}\n          onPointerDown={handlePointerDown}\n          onPointerMove={handlePointerMove}\n          onPointerUp={handlePointerUp}\n          role=\"spinbutton\"\n          style={{ caretColor: \"var(--color-foreground)\" }}\n          type=\"text\"\n          value={currentText}\n        />\n        {stepper ? (\n          <div className=\"relative z-10 mr-1 flex items-center gap-0.5\">\n            <SmoothButton\n              aria-label=\"Decrease value\"\n              disabled={disabled || value <= min}\n              onClick={handleStep(-step)}\n              size=\"icon-sm\"\n              variant=\"ghost\"\n            >\n              <Minus />\n            </SmoothButton>\n            <SmoothButton\n              aria-label=\"Increase value\"\n              disabled={disabled || value >= max}\n              onClick={handleStep(step)}\n              size=\"icon-sm\"\n              variant=\"ghost\"\n            >\n              <Plus />\n            </SmoothButton>\n          </div>\n        ) : null}\n      </motion.div>\n    </div>\n  );\n};\n\nexport default AnimatedNumberInput;\n","path":"index.tsx","target":"components/smoothui/animated-number-input/index.tsx","type":"registry:ui"}],"name":"animated-number-input","registryDependencies":["https://smoothui.dev/r/smooth-button.json"],"title":"Animated Number Input","type":"registry:ui"}