{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-input",
  "type": "registry:ui",
  "title": "Number Input",
  "description": "An accessible React number input for Next.js and Tailwind with step buttons, keyboard control, min and max, and Intl number formatting.",
  "author": "FewTone <https://fewtone.com>",
  "dependencies": [
    "class-variance-authority@^0.7.1",
    "lucide-react@^1.52.0",
    "motion@^14.0.0"
  ],
  "registryDependencies": [
    "https://ui.fewtone.com/r/tokens.json",
    "https://ui.fewtone.com/r/motion.json",
    "https://ui.fewtone.com/r/button.json"
  ],
  "files": [
    {
      "path": "registry/fewtone/ui/number-input.tsx",
      "type": "registry:ui",
      "content": "\"use client\";\n\nimport { Button } from \"@/components/ui/button\";\nimport { blur, duration, ease, spring, swap } from \"@/lib/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { cva, type VariantProps } from \"class-variance-authority\";\nimport { MinusIcon, PlusIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type ComponentProps,\n  type KeyboardEvent,\n  type MouseEvent,\n  type PointerEvent,\n  useEffect,\n  useId,\n  useRef,\n  useState,\n} from \"react\";\n\n/*\n * Number Input: a number field with step buttons (owner, 2026-10-16). It follows the WAI-ARIA spinbutton\n * pattern: a text input with role=\"spinbutton\", ArrowUp/ArrowDown step, PageUp/PageDown take a big step,\n * Home/End go to min/max. Typed text is read when you leave the field or press Enter, then clamped and\n * formatted; a value in range also updates as you type.\n * - Motion (.agents/rules/motion.md): when a step changes the number, it rolls like an odometer. Each digit\n *   that changed rolls a whole line: the old one travels out of the field as the new one travels in from the\n *   other edge (up for up, down for down) on spring.snappy, sharpening through the blur over duration.slow so\n *   the roll reads smooth rather than flickering (owner, 2026-10-21: \"more noticeable\"). Places set off 0.05s\n *   apart from the right, so 19 → 20 turns the ones, then the tens. The field's top and bottom edges fade, so\n *   digits roll in and out of sight softly. The others stay put; typing doesn't animate. While it rolls, the\n *   input's own text is transparent and a copy is drawn over it, character by character, with tabular figures\n *   so nothing jiggles; then the input's text shows again. With reduced motion the number just changes.\n * - The step buttons are our ghost icon Buttons inside the field. They aren't in the tab order (the keys do\n *   the same) and don't take focus. Press and hold steps once, then repeats.\n * - Formatting is optional, through Intl.NumberFormat (`format`, `locale`). A form gets the plain number\n *   through a hidden input.\n * - The field looks like Input: control heights, Input's border, its focus ring around the whole field.\n */\n\n/** Press and hold: one step at once, then after `duration.slower`, one every `duration.instant` */\nconst HOLD_DELAY = duration.slower * 1000;\nconst HOLD_REPEAT = duration.instant * 1000;\n/** Places set off one after another from the right: motion.md's list stagger */\nconst STAGGER = 0.05;\n/**\n * After this long without another step, the rolling copy hands back to the input's own text: the roll's\n * blur (duration.slow) plus room for the spring to land and a few places' stagger\n */\nconst ROLL_MS = duration.slow * 2 * 1000;\n/** The roller's top and bottom edges fade, so digits roll in and out of sight */\nconst ROLL_FADE = \"linear-gradient(to bottom, transparent, black 30%, black 70%, transparent)\";\n\nconst numberInputVariants = cva(\n  [\n    \"relative flex w-full min-w-0 items-center rounded-lg border border-input bg-transparent px-1 text-sm text-foreground shadow-xs\",\n    \"transition duration-(--duration-fast) ease-out\",\n    // The ring goes round the whole field while its input has focus, the way Input's does\n    \"outline-ring has-focus-visible:border-ring has-focus-visible:outline-(length:--focus-ring-width) has-focus-visible:outline-offset-(--focus-ring-offset)\",\n    \"data-invalid:border-destructive data-invalid:outline-destructive\",\n    \"data-disabled:cursor-not-allowed data-disabled:opacity-(--opacity-disabled)\",\n  ],\n  {\n    variants: {\n      size: {\n        sm: \"h-(--size-control-sm) rounded-md\",\n        md: \"h-(--size-control-md)\",\n        lg: \"h-(--size-control-lg)\",\n      },\n    },\n    defaultVariants: { size: \"md\" },\n  },\n);\n\n// The buttons sit 3px inside the field's border at every size\nconst buttonSizes = { sm: \"size-6\", md: \"size-7\", lg: \"size-8\" } as const;\nconst iconSizes = { sm: \"size-(--size-icon-sm)\", md: \"size-(--size-icon-md)\", lg: \"size-(--size-icon-md)\" };\n\n/** Decimal places in a number, so 0.1 + 0.2 steps to 0.3 */\nfunction decimalsOf(value: number) {\n  const [, fraction = \"\", exponent] = /^-?\\d*\\.?(\\d*)(?:e([+-]\\d+))?$/.exec(String(value)) ?? [];\n  return Math.max(0, fraction.length - Number(exponent ?? 0));\n}\n\n/**\n * Reads typed text in the field's locale: group separators, currency signs and spaces are ignored. Empty\n * text is null, and text with no digits is NaN (so the field goes back to the last number).\n */\nfunction parseNumber(text: string, locale: string | undefined, format: Intl.NumberFormatOptions | undefined) {\n  const trimmed = text.trim();\n  if (!trimmed) return null;\n  // Without a format the field shows plain numbers (1234.5), so it reads them the same way\n  const parts = new Intl.NumberFormat(format ? locale : \"en-US\").formatToParts(-12345.6);\n  const group = parts.find((part) => part.type === \"group\")?.value ?? \",\";\n  const decimal = parts.find((part) => part.type === \"decimal\")?.value ?? \".\";\n  // A minus sign anywhere (-$5, $-5, 5-, the typographic −) or accounting brackets, (5.00), is negative\n  const negative = /[-−]/.test(trimmed) || /^\\(.*\\)$/.test(trimmed);\n  const digits = trimmed\n    .split(group)\n    .join(\"\")\n    .split(decimal)\n    .join(\".\")\n    .replace(/[^\\d.]/g, \"\");\n  if (!/\\d/.test(digits)) return Number.NaN;\n  let number = Number(digits);\n  if (!Number.isFinite(number)) return Number.NaN;\n  if (negative) number = -number;\n  // A percent field shows 25% for 0.25\n  if (format?.style === \"percent\") number /= 100;\n  return number;\n}\n\ntype Roll = { direction: 1 | -1; animate: boolean };\n\n/** A digit at rest, and one a whole line above or below it, blurred */\nconst rest = { y: \"0%\", opacity: 1, filter: blur.shown };\nconst away = (side: 1 | -1) => ({ y: `${side * 100}%`, opacity: 0, filter: blur.hidden });\n\n/** Going up, the new digit comes from below and the old one leaves upwards; going down, the other way */\nconst rollVariants = {\n  enter: ({ direction, animate }: Roll) => (animate ? away(direction) : swap.animate),\n  center: rest,\n  exit: ({ direction, animate }: Roll) =>\n    animate\n      ? // The element's own transition: the old digit leaves with the same stagger the new one arrives on\n        away(direction === 1 ? -1 : 1)\n      : { opacity: 0, transition: { duration: 0 } },\n};\n/** The roll for a place: the travel on spring.snappy, the blur and fade on a smooth duration.slow, staggered */\nconst rollTransition = (place: number) => {\n  const delay = Math.max(0, place) * STAGGER;\n  return { duration: duration.slow, ease: ease.out, delay, y: { ...spring.snappy, delay } };\n};\n\n/**\n * The rolling copy of the field's text. Places are counted from the right, so 19 → 20 rolls both digits\n * and 20 → 21 only the last. Each place stacks the old and new character in one cell while they swap.\n */\nfunction Roller({ text, roll }: { text: string; roll: Roll | null }) {\n  const custom = roll ?? { direction: 1, animate: false };\n  const chars = Array.from(text);\n  return (\n    <span\n      aria-hidden\n      data-slot=\"number-input-roll\"\n      className={cn(\n        \"pointer-events-none absolute inset-0 flex items-center justify-center overflow-hidden px-2 tabular-nums\",\n        !roll && \"invisible\",\n      )}\n      style={{ maskImage: ROLL_FADE, WebkitMaskImage: ROLL_FADE }}\n    >\n      {/* A place that goes (21 → 9) pops out of the row so the rest settle at once */}\n      <AnimatePresence initial={false} mode=\"popLayout\" custom={custom}>\n        {chars.map((char, index) => {\n          const place = chars.length - 1 - index;\n          return (\n            <motion.span\n              key={place}\n              custom={custom}\n              variants={rollVariants}\n              initial=\"enter\"\n              animate=\"center\"\n              exit=\"exit\"\n              transition={rollTransition(place)}\n              className=\"inline-grid\"\n            >\n              <AnimatePresence initial={false} custom={custom}>\n                <motion.span\n                  key={char}\n                  custom={custom}\n                  variants={rollVariants}\n                  initial=\"enter\"\n                  animate=\"center\"\n                  exit=\"exit\"\n                  transition={rollTransition(place)}\n                  className=\"col-start-1 row-start-1 whitespace-pre\"\n                >\n                  {char}\n                </motion.span>\n              </AnimatePresence>\n            </motion.span>\n          );\n        })}\n      </AnimatePresence>\n    </span>\n  );\n}\n\nexport type NumberInputProps = Omit<\n  ComponentProps<\"input\">,\n  \"value\" | \"defaultValue\" | \"onChange\" | \"type\" | \"size\" | \"min\" | \"max\" | \"step\"\n> &\n  VariantProps<typeof numberInputVariants> & {\n    /** The number, when you control it; null when the field is empty */\n    value?: number | null;\n    /** The number at first */\n    defaultValue?: number | null;\n    /** Called with the new number, or null when the field is emptied */\n    onValueChange?: (value: number | null) => void;\n    /** The lowest number; Home goes here */\n    min?: number;\n    /** The highest number; End goes here */\n    max?: number;\n    /** How much ArrowUp, ArrowDown and the buttons change it */\n    step?: number;\n    /** How much PageUp and PageDown change it; ten steps if left out */\n    largeStep?: number;\n    /** Intl.NumberFormat options, for example { style: \"currency\", currency: \"USD\" } */\n    format?: Intl.NumberFormatOptions;\n    /** The locale to format and read numbers in; the browser's if left out */\n    locale?: string;\n    /** The increment button's accessible name */\n    incrementLabel?: string;\n    /** The decrement button's accessible name */\n    decrementLabel?: string;\n  };\n\n/**\n * A number field with step buttons. `className` styles the field; other props, including `id` and `ref`,\n * go to its input. Give it a visible label (`<label htmlFor>`).\n */\nfunction NumberInput({\n  className,\n  size,\n  value,\n  defaultValue,\n  onValueChange,\n  min,\n  max,\n  step = 1,\n  largeStep,\n  format,\n  locale,\n  incrementLabel = \"Increase\",\n  decrementLabel = \"Decrease\",\n  id,\n  name,\n  form,\n  disabled = false,\n  readOnly = false,\n  \"aria-invalid\": ariaInvalid,\n  onKeyDown,\n  onBlur,\n  ...props\n}: NumberInputProps) {\n  const fallbackId = useId();\n  const inputId = id ?? fallbackId;\n  // Mirror the value, controlled or not, so stepping and the buttons always start from the latest number\n  const [uncontrolled, setUncontrolled] = useState<number | null>(defaultValue ?? null);\n  const current = value === undefined ? uncontrolled : value;\n  // The text while the user types; null shows the formatted number\n  const [draft, setDraft] = useState<string | null>(null);\n  const [roll, setRoll] = useState<{ direction: 1 | -1; count: number } | null>(null);\n  const reduce = useReducedMotion() ?? false;\n  const latest = useRef(current);\n  latest.current = current;\n\n  const formatter = format ? new Intl.NumberFormat(locale, format) : null;\n  const display = (number: number | null) =>\n    number === null ? \"\" : formatter ? formatter.format(number) : String(number);\n  const text = draft ?? display(current);\n  const clamp = (number: number) =>\n    Math.min(max ?? Number.POSITIVE_INFINITY, Math.max(min ?? Number.NEGATIVE_INFINITY, number));\n  const invalid = ariaInvalid === true || ariaInvalid === \"true\";\n\n  function change(next: number | null) {\n    if (Object.is(next, latest.current)) return;\n    latest.current = next;\n    if (value === undefined) setUncontrolled(next);\n    onValueChange?.(next);\n  }\n\n  /** A change made by stepping, which rolls the number. Returns false when nothing changed (at a bound). */\n  function stepTo(next: number, direction: 1 | -1) {\n    setDraft(null);\n    if (next === latest.current) return false;\n    if (!reduce) setRoll((last) => ({ direction, count: (last?.count ?? 0) + 1 }));\n    change(next);\n    return true;\n  }\n\n  function stepBy(direction: 1 | -1, amount = step) {\n    if (disabled || readOnly) return false;\n    const typed = draft === null ? null : parseNumber(draft, locale, format);\n    const base = typed === null || Number.isNaN(typed) ? latest.current : typed;\n    // From empty, the first step shows 0 (or the nearest bound)\n    if (base === null) return stepTo(clamp(0), direction);\n    const places = Math.max(decimalsOf(base), decimalsOf(amount));\n    return stepTo(clamp(Number((base + direction * amount).toFixed(places))), direction);\n  }\n\n  function goTo(target: number) {\n    if (disabled || readOnly) return;\n    const from = latest.current ?? target;\n    stepTo(target, target >= from ? 1 : -1);\n  }\n\n  /** Reads what was typed: clamped and formatted, or back to the last number when it isn't one */\n  function commit() {\n    if (draft === null) return;\n    const typed = parseNumber(draft, locale, format);\n    setDraft(null);\n    if (typed === null) change(null);\n    else if (!Number.isNaN(typed)) change(clamp(typed));\n  }\n\n  // The rolling copy hands back to the input's own text once the steps stop\n  useEffect(() => {\n    if (!roll) return;\n    const timer = setTimeout(() => setRoll(null), ROLL_MS);\n    return () => clearTimeout(timer);\n  }, [roll]);\n\n  // Press and hold. The timers call the latest stepBy, so each repeat starts from the latest number.\n  const stepRef = useRef(stepBy);\n  stepRef.current = stepBy;\n  const hold = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);\n  const pointerStep = useRef(false);\n  const stopHold = () => clearTimeout(hold.current);\n  useEffect(() => () => clearTimeout(hold.current), []);\n\n  function startHold(direction: 1 | -1) {\n    stopHold();\n    if (!stepRef.current(direction)) return;\n    const repeat = (wait: number) => {\n      hold.current = setTimeout(() => {\n        if (stepRef.current(direction)) repeat(HOLD_REPEAT);\n      }, wait);\n    };\n    repeat(HOLD_DELAY);\n  }\n\n  const atMin = current !== null && min !== undefined && current <= min;\n  const atMax = current !== null && max !== undefined && current >= max;\n\n  const stepButton = (direction: 1 | -1) => ({\n    variant: \"ghost\" as const,\n    size: \"icon-sm\" as const,\n    // The keys do the same, so the buttons stay out of the tab order (WAI-ARIA spinbutton)\n    tabIndex: -1,\n    \"aria-label\": direction > 0 ? incrementLabel : decrementLabel,\n    \"aria-controls\": inputId,\n    disabled: disabled || readOnly || (direction > 0 ? atMax : atMin),\n    className: cn(\"text-muted-foreground hover:text-foreground\", buttonSizes[size ?? \"md\"]),\n    // Pressing doesn't take focus from the input\n    onMouseDown: (event: MouseEvent<HTMLButtonElement>) => event.preventDefault(),\n    onPointerDown: (event: PointerEvent<HTMLButtonElement>) => {\n      if (event.button !== 0) return;\n      event.preventDefault();\n      pointerStep.current = true;\n      startHold(direction);\n    },\n    onPointerUp: stopHold,\n    onPointerLeave: stopHold,\n    onPointerCancel: stopHold,\n    onClick: (event: MouseEvent<HTMLButtonElement>) => {\n      // A pointer already stepped on press; a click from the keyboard or a screen reader steps here\n      if (event.detail === 0 || !pointerStep.current) stepRef.current(direction);\n      pointerStep.current = false;\n    },\n  });\n\n  return (\n    <div\n      data-slot=\"number-input\"\n      data-size={size ?? \"md\"}\n      data-invalid={invalid || undefined}\n      data-disabled={disabled || undefined}\n      className={cn(numberInputVariants({ size }), className)}\n    >\n      <Button {...stepButton(-1)}>\n        <MinusIcon aria-hidden className={iconSizes[size ?? \"md\"]} />\n      </Button>\n      <div className=\"relative h-full min-w-0 flex-1\">\n        <input\n          id={inputId}\n          type=\"text\"\n          role=\"spinbutton\"\n          inputMode=\"decimal\"\n          autoComplete=\"off\"\n          autoCorrect=\"off\"\n          spellCheck={false}\n          aria-valuenow={current ?? undefined}\n          aria-valuemin={min}\n          aria-valuemax={max}\n          aria-valuetext={formatter && current !== null ? formatter.format(current) : undefined}\n          aria-invalid={ariaInvalid}\n          disabled={disabled}\n          readOnly={readOnly}\n          data-slot=\"number-input-input\"\n          value={text}\n          onChange={(event) => {\n            const next = event.target.value;\n            setDraft(next);\n            setRoll(null);\n            // A number in range updates as you type; anything else waits until you leave the field\n            const typed = parseNumber(next, locale, format);\n            if (typed === null) change(null);\n            else if (!Number.isNaN(typed) && typed === clamp(typed)) change(typed);\n          }}\n          onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {\n            onKeyDown?.(event);\n            if (event.defaultPrevented || disabled || readOnly) return;\n            const big = largeStep ?? step * 10;\n            switch (event.key) {\n              case \"ArrowUp\":\n                stepBy(1);\n                break;\n              case \"ArrowDown\":\n                stepBy(-1);\n                break;\n              case \"PageUp\":\n                stepBy(1, big);\n                break;\n              case \"PageDown\":\n                stepBy(-1, big);\n                break;\n              case \"Home\":\n                if (min === undefined) return;\n                goTo(min);\n                break;\n              case \"End\":\n                if (max === undefined) return;\n                goTo(max);\n                break;\n              case \"Enter\":\n                // Read the typed number before the form submits\n                commit();\n                return;\n              default:\n                return;\n            }\n            event.preventDefault();\n          }}\n          onBlur={(event) => {\n            commit();\n            onBlur?.(event);\n          }}\n          className={cn(\n            \"size-full min-w-0 bg-transparent px-2 text-center tabular-nums outline-hidden placeholder:text-muted-foreground disabled:cursor-not-allowed\",\n            roll && !reduce && \"text-transparent\",\n          )}\n          {...props}\n        />\n        <Roller text={text} roll={roll && !reduce ? { direction: roll.direction, animate: true } : null} />\n      </div>\n      <Button {...stepButton(1)}>\n        <PlusIcon aria-hidden className={iconSizes[size ?? \"md\"]} />\n      </Button>\n      {name ? (\n        <input type=\"hidden\" name={name} form={form} value={current ?? \"\"} disabled={disabled} />\n      ) : null}\n    </div>\n  );\n}\n\nexport { NumberInput, numberInputVariants };\n"
    }
  ],
  "categories": [
    "forms"
  ],
  "docs": "Usage and examples: https://ui.fewtone.com/components/number-input"
}
