Inputs

Number Input

A capsule quantity stepper — decrement/input/increment — with clamping, boundary-disabled buttons, and keyboard arrow stepping.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { Minus, Plus } from "lucide-react"
import { cn } from "@/lib/utils"

export interface NumberInputProps
  extends Omit<React.InputHTMLAttributes<HTMLInputElement>, "value" | "onChange" | "type" | "min" | "max" | "step"> {
  value: number
  onChange: (value: number) => void
  min?: number
  max?: number
  step?: number
}

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/number-input.json

Prompt

The prompt behind this component — paste it into your AI assistant to recreate or adapt it.

Build a React + TypeScript + Tailwind "NumberInput" component (lucide-react
Plus/Minus).

Contract
- Export a forwardRef component whose ref points at the inner <input>.
- Controlled only: value: number, onChange: (value: number) => void.
- min?: number, max?: number, step?: number (default 1), disabled?: boolean,
  className?: string, plus the rest of InputHTMLAttributes<HTMLInputElement>
  (minus value/onChange/type/min/max/step, which this component owns).

Behavior
- Renders one pill: [decrease button][input][increase button].
- Clicking +/- steps value by ±step, rounds to the decimal precision implied
  by step itself (so 0.1 + 0.2 never leaves a floating-point tail), clamps
  to [min, max], and calls onChange with the result.
- When the clamped result would sit at min or max, the corresponding button
  is disabled (not just inert-on-click) — the boundary is visible before
  the user tries it.
- The input accepts direct typing: digits and a leading "-" always; a
  single "." is additionally allowed only when step has a fractional part
  (an integer-step field never accepts a decimal point). Keystrokes are
  held in a local "draft" string — nothing is clamped or reformatted while
  typing, so mid-edit states like "-" or "1." are not fought.
- On blur: parse the draft. If it doesn't parse to a number (empty, a bare
  "-", NaN), revert the draft to the last committed value — never commit
  garbage. If it parses, round to step's precision, clamp to [min, max],
  call onChange, and reformat the draft to the clamped/rounded value.
- ArrowUp / ArrowDown while the input is focused step the value by
  +step/-step through the exact same clamp path as the buttons — this is
  the primary keyboard path, per the APG spinbutton convention.
- The +/- buttons are tabIndex={-1} and are never a Tab stop — keyboard
  users operate the field entirely through the input's arrow keys; the
  buttons are pointer/touch affordances.
- disabled disables the input and both buttons and dims the container.

Rendering & styling
- Outer pill: inline-flex items-center rounded-md border border-input
  bg-transparent; disabled adds cursor-not-allowed opacity-50.
- Buttons: size-9, flex items-center justify-center, hover:bg-muted,
  text-muted-foreground, rounded-l-md / rounded-r-md on the outer corners
  only, disabled:opacity-40 disabled:pointer-events-none.
- Input: h-9 w-14 bg-transparent text-center tabular-nums outline-none —
  no border of its own, so the pill's single outer border reads as one
  control, not three. Use type="text" + inputMode="numeric" rather than
  type="number", so there's never a competing native spinner UI.
- role="spinbutton" plus aria-valuemin / aria-valuemax (only emitted when
  min/max are finite) / aria-valuenow track the live numeric value for
  assistive tech, even though the element stays a type="text" input.
- Semantic tokens only. Merge the consumer className via cn() onto the
  outer pill.

Customization levers
- Long-press auto-repeat: this component steps once per click/keydown. To
  add press-and-hold repeat, wrap the button handler in a setInterval
  started on onPointerDown and cleared on onPointerUp/onPointerLeave.
- Sizing: size-9 buttons / w-14 input are the density knobs — scale both
  together (e.g. size-8/w-12 compact, size-10/w-16 touch-friendly).
- Unit suffix: to show "kg" or "%" after the value, render a static span
  inside the pill after the input — purely decorative, doesn't affect
  value/precision.
- React Hook Form: this is a controlled value/onChange pair, not a native
  change event — wire it with
  `<Controller name="qty" control={form.control} render={({ field }) => <NumberInput onChange={field.onChange} value={field.value} />} />`
  rather than spreading `{...field}` directly.

Concepts

  • Draft vs committed value — typing edits a local draft string; nothing is clamped or rounded until blur, so intermediate states like - or 1. aren't fought mid-keystroke.
  • Step-derived precision — the number of decimal places is read straight off step (0.5 → 1 decimal), so increments never leave floating-point tails like 2.3000000000000003.
  • Boundary disables, doesn't clamp silently — hitting min/max disables the corresponding button instead of leaving it clickable and clamping with no visible feedback.
  • APG spinbutton tabIndex convention — the +/- buttons are tabIndex={-1}; keyboard users step through the input's own ArrowUp/ArrowDown, keeping Tab order to one stop per field.
  • Illegal input falls back — an unparsable draft (empty, bare -, NaN) reverts to the last committed value on blur rather than committing garbage.

On This Page