{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "number-input",
  "title": "Number Input",
  "description": "A number field with − and + stepper buttons either side of it. Reach for it wherever someone adjusts a number by one rather than typing it: a quantity picker in a cart, checkout or order form, seats, guests, rooms, tickets or attendees, a per-page or page-size control, retries, timeout or concurrency in a settings panel, font size, padding or spacing in an editor — anywhere you would otherwise reach for a bare <input type=\"number\"> or a spinbutton. It keeps type=\"number\" underneath, so native validation and valueAsNumber still work, and fixes the parts of it that are unpleasant in practice: the browser's own spin buttons are hidden (they are inconsistent across browsers and absent on mobile) and replaced with real, theme-aware, keyboard-reachable ones, inputMode=\"decimal\" brings up the numeric keypad on phones, and the value is tabular-nums so the digits do not jump as they change. Decimal steps do not drift: stepping by 0.1 counts the step's decimal places and rounds to them, so you get 0.3 rather than 0.30000000000000004. Each button clamps to min or max and disables itself once the value is at that bound, and the buttons are taken out of the tab order so Tab still lands on the field itself. The step is written through the input's native value setter and dispatches a real input event, which is the part that is easy to get wrong: onChange fires whether the field is controlled or uncontrolled, so react-hook-form, Formik and plain useState all see the change instead of silently missing button presses. Only one of value/defaultValue ever reaches the input, so React never warns about switching between controlled and uncontrolled. Official shadcn/ui has no number field: its input is a bare 768-byte element, and input-group and field are assembly kits that pull in button, input, textarea, label and separator without a line of numeric logic between them — no stepping, no clamping, no min/max state. This depends only on lucide-react for the two icons.",
  "dependencies": [
    "lucide-react"
  ],
  "files": [
    {
      "path": "registry/ui/number-input.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { Minus, Plus } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\ninterface NumberInputProps\n  extends Omit<React.ComponentPropsWithoutRef<\"input\">, \"type\"> {\n  /** Smallest allowed value; the − button stops here and it sets the input's min. */\n  min?: number\n  /** Largest allowed value; the + button stops here and it sets the input's max. */\n  max?: number\n  /** Amount added or removed per step (default 1). Decimal steps are supported. */\n  step?: number\n}\n\n// Count a step's decimals so repeated 0.1-style nudges don't drift\n// (0.1 + 0.2 -> 0.30000000000000004).\nfunction decimalPlaces(n: number) {\n  const s = String(n)\n  const dot = s.indexOf(\".\")\n  return dot === -1 ? 0 : s.length - dot - 1\n}\n\n// Write through the prototype's value setter so React's onChange fires for both\n// controlled and uncontrolled inputs (mirrors the search-input pattern).\nfunction setNativeValue(input: HTMLInputElement, value: string) {\n  const setter = Object.getOwnPropertyDescriptor(\n    window.HTMLInputElement.prototype,\n    \"value\"\n  )?.set\n  setter?.call(input, value)\n  input.dispatchEvent(new Event(\"input\", { bubbles: true }))\n}\n\nexport const NumberInput = React.forwardRef<HTMLInputElement, NumberInputProps>(\n  function NumberInput(\n    {\n      className,\n      min,\n      max,\n      step = 1,\n      disabled,\n      value,\n      defaultValue,\n      onChange,\n      ...props\n    },\n    forwardedRef\n  ) {\n    const innerRef = React.useRef<HTMLInputElement>(null)\n    React.useImperativeHandle(\n      forwardedRef,\n      () => innerRef.current as HTMLInputElement\n    )\n\n    const isControlled = value !== undefined\n    const [current, setCurrent] = React.useState(() =>\n      String((isControlled ? value : defaultValue) ?? \"\")\n    )\n\n    // Keep the bound buttons in sync when the value is controlled.\n    React.useEffect(() => {\n      if (isControlled) setCurrent(String(value ?? \"\"))\n    }, [isControlled, value])\n\n    function nudge(direction: 1 | -1) {\n      const input = innerRef.current\n      if (!input || disabled) return\n      const parsed = Number(current)\n      const base = current !== \"\" && Number.isFinite(parsed) ? parsed : 0\n      let next = base + direction * step\n      if (min !== undefined && next < min) next = min\n      if (max !== undefined && next > max) next = max\n      const places = decimalPlaces(step)\n      setNativeValue(input, places > 0 ? next.toFixed(places) : String(next))\n      input.focus()\n    }\n\n    function handleChange(e: React.ChangeEvent<HTMLInputElement>) {\n      setCurrent(e.target.value)\n      onChange?.(e)\n    }\n\n    const numeric = current === \"\" ? null : Number(current)\n    const valid = numeric !== null && Number.isFinite(numeric)\n    const atMin = valid && min !== undefined && (numeric as number) <= min\n    const atMax = valid && max !== undefined && (numeric as number) >= max\n\n    // Only one of value/defaultValue ever reaches the input so React never warns\n    // about switching between controlled and uncontrolled.\n    const controlledProps = isControlled ? { value } : { defaultValue }\n\n    return (\n      <div\n        className={cn(\n          \"inline-flex h-9 items-center rounded-md border border-input bg-transparent shadow-sm transition-colors focus-within:ring-1 focus-within:ring-ring\",\n          disabled && \"cursor-not-allowed opacity-50\",\n          className\n        )}\n      >\n        <button\n          type=\"button\"\n          onClick={() => nudge(-1)}\n          disabled={disabled || atMin}\n          aria-label=\"Decrease\"\n          tabIndex={-1}\n          className=\"inline-flex h-full w-9 shrink-0 items-center justify-center rounded-l-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n        >\n          <Minus className=\"h-4 w-4\" aria-hidden=\"true\" />\n        </button>\n        <input\n          ref={innerRef}\n          type=\"number\"\n          inputMode=\"decimal\"\n          min={min}\n          max={max}\n          step={step}\n          disabled={disabled}\n          onChange={handleChange}\n          className=\"h-full w-14 min-w-0 border-x border-input bg-transparent px-2 text-center text-sm tabular-nums outline-none [appearance:textfield] placeholder:text-muted-foreground disabled:cursor-not-allowed [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none\"\n          {...controlledProps}\n          {...props}\n        />\n        <button\n          type=\"button\"\n          onClick={() => nudge(1)}\n          disabled={disabled || atMax}\n          aria-label=\"Increase\"\n          tabIndex={-1}\n          className=\"inline-flex h-full w-9 shrink-0 items-center justify-center rounded-r-md text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50\"\n        >\n          <Plus className=\"h-4 w-4\" aria-hidden=\"true\" />\n        </button>\n      </div>\n    )\n  }\n)\n",
      "type": "registry:ui"
    }
  ],
  "type": "registry:ui"
}