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…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/number-input.jsonPrompt
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
-or1.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 like2.3000000000000003. - Boundary disables, doesn't clamp silently — hitting
min/maxdisables the corresponding button instead of leaving it clickable and clamping with no visible feedback. - APG spinbutton
tabIndexconvention — the +/- buttons aretabIndex={-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.