{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"number-input","type":"registry:ui","title":"Number Input","description":"Number field with steppers on both sides or stacked on the right, min/max/step, full keyboard control and press-and-hold that speeds up; digits roll to each new value on a spring.","author":"GetUrUI <hello@geturui.dev>","dependencies":["motion","lucide-react"],"registryDependencies":["@geturui/field"],"files":[{"path":"registry/geturui/ui/number-input.tsx","type":"registry:ui","target":"components/ui/number-input.tsx","content":"\"use client\";\n\nimport * as React from \"react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { ChevronDown, ChevronUp, Minus, Plus } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport { Field, fieldWellClasses, type FieldSize } from \"@/components/ui/field\";\n\nexport interface NumberInputProps\n  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, \"size\" | \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"min\" | \"max\" | \"step\"> {\n  value?: number | null;\n  defaultValue?: number | null;\n  /** Called with the new value (null when the field is emptied). */\n  onValueChange?: (value: number | null) => void;\n  min?: number;\n  max?: number;\n  /** Amount per arrow press or stepper tap; its decimals set the precision. @default 1 */\n  step?: number;\n  /** Amount for PageUp/PageDown and Shift+Arrow. @default step × 10 */\n  largeStep?: number;\n  /** @default \"md\" */\n  size?: FieldSize;\n  /** Steppers on both sides, or stacked chevrons on the right. @default \"horizontal\" */\n  orientation?: \"horizontal\" | \"vertical\";\n  /** Unit shown after the value, e.g. \"seats\" or \"%\". */\n  unit?: string;\n  /** Locale for formatting. Fixed by default so server and client render the same. @default \"en-US\" */\n  locale?: string;\n  formatOptions?: Intl.NumberFormatOptions;\n  label?: React.ReactNode;\n  hint?: React.ReactNode;\n  error?: React.ReactNode;\n  labelAside?: React.ReactNode;\n  wrapperClassName?: string;\n  /** Accessible names for the steppers. */\n  incrementLabel?: string;\n  decrementLabel?: string;\n}\n\nconst stepperSize: Record<FieldSize, string> = { sm: \"size-6\", md: \"size-8\", lg: \"size-10\" };\nconst iconSize: Record<FieldSize, string> = { sm: \"size-3.5\", md: \"size-4\", lg: \"size-[18px]\" };\n\nfunction decimalsOf(n: number) {\n  const s = String(n);\n  if (s.includes(\"e-\")) return Number(s.split(\"e-\")[1]);\n  return (s.split(\".\")[1] ?? \"\").length;\n}\n\n/** One rolling digit column: a 0–9 strip that springs to the current digit. */\nfunction DigitColumn({ digit, reduce }: { digit: number; reduce: boolean }) {\n  return (\n    <span className=\"relative inline-flex h-[1.25em] flex-col overflow-hidden leading-[1.25em]\">\n      <span className=\"invisible\">0</span>\n      <motion.span\n        aria-hidden\n        className=\"absolute inset-x-0 top-0 flex flex-col\"\n        initial={false}\n        animate={{ y: `${-digit * 1.25}em` }}\n        transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 320, damping: 30 }}\n      >\n        {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((n) => (\n          <span key={n} className=\"block h-[1.25em] text-center\">\n            {n}\n          </span>\n        ))}\n      </motion.span>\n    </span>\n  );\n}\n\nfunction RollingNumber({ text, reduce }: { text: string; reduce: boolean }) {\n  const chars = text.split(\"\");\n  return (\n    <span className=\"inline-flex items-center tabular-nums\" aria-hidden>\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        {chars.map((c, i) => {\n          const fromRight = chars.length - i;\n          const isDigit = c >= \"0\" && c <= \"9\";\n          return (\n            <motion.span\n              key={isDigit ? `d${fromRight}` : `s${fromRight}${c}`}\n              layout={reduce ? false : \"position\"}\n              initial={reduce ? { opacity: 0 } : { opacity: 0, filter: \"blur(4px)\", y: \"-0.4em\" }}\n              animate={{ opacity: 1, filter: \"blur(0px)\", y: 0 }}\n              exit={reduce ? { opacity: 0 } : { opacity: 0, filter: \"blur(4px)\", y: \"0.4em\" }}\n              transition={{ type: \"spring\", stiffness: 380, damping: 32 }}\n              className=\"inline-flex\"\n            >\n              {isDigit ? <DigitColumn digit={Number(c)} reduce={reduce} /> : <span className=\"whitespace-pre\">{c}</span>}\n            </motion.span>\n          );\n        })}\n      </AnimatePresence>\n    </span>\n  );\n}\n\n/**\n * Number field with steppers, min/max/step, arrow/Page/Home/End keys and press-and-hold repeat that speeds up;\n * digits roll to their new value on a spring.\n */\nexport const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(function NumberInput(\n  {\n    value,\n    defaultValue = null,\n    onValueChange,\n    min,\n    max,\n    step = 1,\n    largeStep,\n    size = \"md\",\n    orientation = \"horizontal\",\n    unit,\n    locale = \"en-US\",\n    formatOptions,\n    label,\n    hint,\n    error,\n    labelAside,\n    required,\n    disabled,\n    readOnly,\n    id,\n    className,\n    wrapperClassName,\n    incrementLabel = \"Increase\",\n    decrementLabel = \"Decrease\",\n    onKeyDown,\n    onBlur,\n    ...props\n  },\n  ref,\n) {\n  const reduce = useReducedMotion() ?? false;\n  const [inner, setInner] = React.useState<number | null>(defaultValue);\n  const current = value !== undefined ? value : inner;\n  const [draft, setDraft] = React.useState<string | null>(null);\n  const valueRef = React.useRef(current);\n  const repeat = React.useRef<ReturnType<typeof setTimeout> | null>(null);\n  const big = largeStep ?? step * 10;\n  const decimals = decimalsOf(step);\n\n  React.useEffect(() => {\n    valueRef.current = current;\n  }, [current]);\n  React.useEffect(\n    () => () => {\n      if (repeat.current) clearTimeout(repeat.current);\n    },\n    [],\n  );\n\n  const format = React.useMemo(\n    () => new Intl.NumberFormat(locale, formatOptions ?? { minimumFractionDigits: decimals, maximumFractionDigits: decimals }),\n    [locale, formatOptions, decimals],\n  );\n  const formatted = current === null ? \"\" : format.format(current);\n\n  const clamp = React.useCallback(\n    (n: number) => {\n      const rounded = Number(n.toFixed(Math.min(decimals, 20)));\n      return Math.min(max ?? Infinity, Math.max(min ?? -Infinity, rounded));\n    },\n    [min, max, decimals],\n  );\n\n  const commitValue = React.useCallback(\n    (next: number | null) => {\n      valueRef.current = next;\n      if (value === undefined) setInner(next);\n      onValueChange?.(next);\n    },\n    [value, onValueChange],\n  );\n\n  /** Steps from the latest value; returns false when it hits a bound so hold-to-repeat can stop. */\n  const stepBy = React.useCallback(\n    (amount: number) => {\n      const base = valueRef.current ?? (amount > 0 ? (min ?? 0) - amount : (max ?? 0) - amount);\n      const next = clamp(base + amount);\n      setDraft(null);\n      if (next === valueRef.current) return false;\n      commitValue(next);\n      return !(next === max || next === min);\n    },\n    [clamp, commitValue, min, max],\n  );\n\n  const stopRepeat = () => {\n    if (repeat.current) clearTimeout(repeat.current);\n    repeat.current = null;\n  };\n\n  const startRepeat = (amount: number) => {\n    stopRepeat();\n    if (!stepBy(amount)) return;\n    let count = 0;\n    const tick = () => {\n      count++;\n      if (!stepBy(amount)) return stopRepeat();\n      repeat.current = setTimeout(tick, count > 10 ? 35 : count > 4 ? 60 : 100);\n    };\n    repeat.current = setTimeout(tick, 420);\n  };\n\n  function commitDraft() {\n    if (draft === null) return;\n    const cleaned = draft.replace(/[^\\d.\\-]/g, \"\");\n    setDraft(null);\n    if (cleaned === \"\" || cleaned === \"-\" || cleaned === \".\") return commitValue(null);\n    const n = Number(cleaned);\n    if (Number.isFinite(n)) commitValue(clamp(n));\n  }\n\n  const atMin = current !== null && min !== undefined && current <= min;\n  const atMax = current !== null && max !== undefined && current >= max;\n  const inert = disabled || readOnly;\n\n  const stepper = (dir: 1 | -1, kind: \"h\" | \"v\", controls: string) => {\n    const blocked = inert || (dir > 0 ? atMax : atMin);\n    const Icon = kind === \"h\" ? (dir > 0 ? Plus : Minus) : dir > 0 ? ChevronUp : ChevronDown;\n    return (\n      <motion.button\n        type=\"button\"\n        tabIndex={-1}\n        aria-label={dir > 0 ? incrementLabel : decrementLabel}\n        aria-controls={controls}\n        disabled={blocked}\n        whileTap={blocked || reduce ? undefined : { scale: 0.88 }}\n        transition={{ type: \"spring\", stiffness: 600, damping: 26 }}\n        onPointerDown={(e) => {\n          if (e.button !== 0) return;\n          e.preventDefault(); // keep focus where it is\n          (e.currentTarget as HTMLButtonElement).setPointerCapture?.(e.pointerId);\n          startRepeat(dir * step);\n        }}\n        onPointerUp={stopRepeat}\n        onPointerCancel={stopRepeat}\n        onLostPointerCapture={stopRepeat}\n        onClick={(e) => {\n          // Screen reader / keyboard activation has no pointerdown.\n          if (e.detail === 0) stepBy(dir * step);\n        }}\n        className={cn(\n          \"grid shrink-0 place-items-center text-ink-muted outline-none transition-colors hover:bg-surface-hover hover:text-ink disabled:pointer-events-none disabled:opacity-35\",\n          kind === \"h\" ? cn(stepperSize[size], \"rounded-[7px]\") : \"min-h-0 w-7 flex-1\",\n        )}\n      >\n        <Icon className={kind === \"h\" ? iconSize[size] : \"size-3.5\"} aria-hidden />\n      </motion.button>\n    );\n  };\n\n  const centered = orientation === \"horizontal\";\n\n  return (\n    <Field label={label} hint={hint} error={error} required={required} labelAside={labelAside} id={id} disabled={disabled} className={wrapperClassName}>\n      {(ids) => (\n        <div\n          className={fieldWellClasses({\n            size,\n            invalid: ids.invalid,\n            disabled,\n            className: cn(centered ? \"px-1\" : \"pr-0\", className),\n          })}\n        >\n          {centered && stepper(-1, \"h\", ids.id)}\n          <div className=\"relative flex min-w-0 flex-1 self-stretch\">\n            <input\n              ref={ref}\n              id={ids.id}\n              type=\"text\"\n              inputMode={decimals > 0 ? \"decimal\" : \"numeric\"}\n              role=\"spinbutton\"\n              autoComplete=\"off\"\n              aria-valuenow={current ?? undefined}\n              aria-valuemin={min}\n              aria-valuemax={max}\n              aria-valuetext={current === null ? undefined : unit ? `${formatted} ${unit}` : formatted}\n              aria-invalid={ids.invalid || undefined}\n              aria-describedby={ids.describedBy}\n              required={required}\n              disabled={disabled}\n              readOnly={readOnly}\n              value={draft ?? formatted}\n              onChange={(e) => setDraft(e.target.value)}\n              onBlur={(e) => {\n                commitDraft();\n                onBlur?.(e);\n              }}\n              onKeyDown={(e) => {\n                onKeyDown?.(e);\n                if (e.defaultPrevented || inert) return;\n                const k = e.key;\n                if (k === \"ArrowUp\" || k === \"ArrowDown\") {\n                  e.preventDefault();\n                  if (draft !== null) commitDraft();\n                  stepBy((k === \"ArrowUp\" ? 1 : -1) * (e.shiftKey ? big : step));\n                } else if (k === \"PageUp\" || k === \"PageDown\") {\n                  e.preventDefault();\n                  stepBy((k === \"PageUp\" ? 1 : -1) * big);\n                } else if (k === \"Home\" && min !== undefined) {\n                  e.preventDefault();\n                  setDraft(null);\n                  commitValue(min);\n                } else if (k === \"End\" && max !== undefined) {\n                  e.preventDefault();\n                  setDraft(null);\n                  commitValue(max);\n                } else if (k === \"Enter\") {\n                  commitDraft();\n                } else if (k === \"Escape\" && draft !== null) {\n                  e.preventDefault();\n                  setDraft(null);\n                }\n              }}\n              {...props}\n              className={cn(\n                \"w-full min-w-0 self-stretch bg-transparent tabular-nums text-ink outline-none focus-visible:shadow-none placeholder:text-ink-subtle disabled:cursor-not-allowed\",\n                centered ? \"text-center\" : \"text-left\",\n                draft === null && current !== null && \"text-transparent caret-transparent selection:bg-transparent\",\n              )}\n            />\n            {draft === null && current !== null && (\n              <span\n                className={cn(\n                  \"pointer-events-none absolute inset-0 flex items-center gap-1 text-ink\",\n                  centered ? \"justify-center\" : \"justify-start\",\n                )}\n              >\n                <RollingNumber text={formatted} reduce={reduce} />\n                {unit && <span className=\"text-ink-subtle\">{unit}</span>}\n              </span>\n            )}\n          </div>\n          {centered && stepper(1, \"h\", ids.id)}\n          {!centered && (\n            <div className=\"ml-2 flex w-7 flex-col self-stretch overflow-hidden rounded-r-[inherit] border-l border-border-strong\">\n              {stepper(1, \"v\", ids.id)}\n              <span aria-hidden className=\"h-px bg-border-strong\" />\n              {stepper(-1, \"v\", ids.id)}\n            </div>\n          )}\n        </div>\n      )}\n    </Field>\n  );\n});\n"},{"path":"LICENSE-geturui.md","type":"registry:file","target":"LICENSE-geturui.md","content":"MIT License\n\nCopyright (c) 2026 geturui.com\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"}],"categories":["components","number","stepper","spinbutton","quantity","input","form","counter"],"meta":{"tier":"free","license":"MIT","docs":"https://geturui.com/docs/number-input"}}