Number Input

Free · MIT

Numeric spinbutton with split, end or stacked steppers, press-and-hold acceleration, min/max/step and large steps, Intl currency, percent and unit formatting over a raw value, arrow, PageUp/PageDown, Home/End and focused-wheel stepping, slide or pop step feedback and an invalid state, in the six input variants with tones, sizes, shapes and shadows.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/number-input.json

The file lands in components/goodui/number-input.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/number-input works too.

Usage

import * as React from "react"
import { NumberInput } from "@/components/goodui/number-input"

export function SeatsField() {
  const [seats, setSeats] = React.useState<number | null>(5)
  const [discount, setDiscount] = React.useState<number | null>(0.1)

  return (
    <div className="grid gap-4 sm:grid-cols-2">
      <NumberInput label="Seats" value={seats} onValueChange={setSeats} min={1} max={250} steppers="split" name="seats" />
      <NumberInput
        label="Discount"
        value={discount}
        onValueChange={setDiscount}
        min={0}
        max={0.5}
        step={0.05}
        format={{ style: "percent" }}
        description="Applied to every seat."
      />
    </div>
  )
}

When to use

Use it for
  • Quantities with a sensible range: seats, guests, items in a cart, team size.
  • Money, percentages and units that should read formatted ($1,250.00, 15%, 45 minutes) but stay a raw number in state.
  • Values people nudge more than type: buffers between bookings, session length in 15-minute steps, discounts in 5% steps.
Reach for something else
  • Picking a value on a wide continuous range where precision does not matter: use slider-tooltip.
  • Codes that only look numeric (PINs, ZIP codes, card numbers): use input or otp-input, they are not quantities.
  • Two to five fixed choices (1, 2, 4 guests): use toggle-group so every option is visible.
If you needUse
Quantity with big tap targets (mobile, POS)steppers="split" with shape="pill"
Compact form fieldsteppers="stacked" or "end" (default)
Plain typed numbersteppers="none"
Moneyformat={{ style: "currency", currency: "EUR" }} and a locale
Percentformat={{ style: "percent" }}; the value is a fraction (0.15)
Durations and unitsformat={{ style: "unit", unit: "minute", unitDisplay: "long" }}
Keep out-of-range values and flag themclampOnBlur={false} with message

Examples

Currency in a fixed locale

<NumberInput
  label="Deposit"
  locale="de-DE"
  format={{ style: "currency", currency: "EUR" }}
  min={0}
  step={50}
  largeStep={500}
  defaultValue={1250}
/>

Flag instead of clamping

<NumberInput label="Guests per table" min={1} max={8} clampOnBlur={false} message="Tables seat up to 8 guests." trailing="guests" />

Duration with stacked steppers

<NumberInput label="Session length" min={15} max={240} step={15} defaultValue={45} steppers="stacked"
  format={{ style: "unit", unit: "minute", unitDisplay: "long" }} />

API reference

PropTypeDefault
value
Raw numeric value (controlled); null when empty. Formatting never changes it.
number | null—
defaultValue
Initial value when uncontrolled.
number | nullnull
onValueChange
Called with the raw number (or null) while typing, on every step and when the value is clamped on blur.
(value: number | null) => void—
onChange
Native change event of the text field (fires while typing only).
React.ChangeEventHandler<HTMLInputElement>—
minnumber—
maxnumber—
step
Amount added by a stepper, ↑ / ↓ or the wheel.
number1
largeStep
Amount added by PageUp / PageDown and Shift + wheel. Defaults to step × 10.
number—
format
Intl.NumberFormat options for the display, e.g. { style: "currency", currency: "EUR" } or { style: "percent" }. Percent values are fractions (0.15 shows as 15%); you type 15 while editing.
Intl.NumberFormatOptions—
locale
BCP 47 locale for formatting and parsing. Defaults to the GooduiLocaleProvider language, else the browser's language after mount ("en-US" on the server).
string—
clampOnBlur
Snap out-of-range values back to min / max when the field loses focus. false keeps them and marks the field invalid.
booleantrue
holdToRepeat
Repeat while a stepper is held down, accelerating the longer it is held.
booleantrue
wheel
Step with the mouse wheel while the field has focus.
booleantrue
sound
Plays a "tick" on every step (a little higher for +, lower for −) when the visitor has sounds on (lib/goodui-sounds); pass a sound name to pick another. Off by default.
boolean | SoundName—
steppers
Stepper buttons: split puts − and + on either side, end groups them after the value, stacked shows ▲ ▼.
"split" | "end" | "stacked" | "none""end"
align
Text alignment of the value. Defaults to center for split steppers, start otherwise.
"start" | "center" | "end"—
variant
Field style: outline (default), filled, underline, ghost, neo-brutalist or frosted glass.
"outline" | "filled" | "underline" | "ghost" | "neo" | "glass""outline"
tone
Focus color and stepper hover color.
"primary" | "neutral" | "success" | "warning" | "danger" | "info""primary"
size"sm" | "md" | "lg""md"
shape"square" | "rounded" | "pill""rounded"
shadow
Field shadow; inner draws an inset shadow. Ignored by neo, which has its own offset shadow.
"none" | "sm" | "md" | "inner""none"
animation
How the field reacts to focus.
"none" | "ring" | "glow" | "lift""ring"
stepAnimation
How the value reacts to a step: slide (up or down with the direction), pop (scale bump) or none.
"none" | "slide" | "pop""slide"
label
Visible label above the field.
React.ReactNode—
description
Helper text under the field (hidden while a message shows).
React.ReactNode—
message
Error text under the field; shown when invalid.
React.ReactNode—
invalid
Red field and aria-invalid. Also set automatically for out-of-range values when clampOnBlur is false.
boolean—
leading
Icon or short text before the value.
React.ReactNode—
trailing
Icon or short text after the value (e.g. "guests").
React.ReactNode—
fullWidth
Stretch to the container. By default the field is 12rem wide (override with className).
boolean—
fieldClassName
Class for the field box (the root gets className).
string—
labels
Text for built-in controls and screen readers; pass translations here.
Partial<NumberInputLabels>—

Also accepts every <input> attribute.

Accessibility

↑ / ↓Add or subtract step (Shift: largeStep).
PageUp / PageDownAdd or subtract largeStep (default step × 10).
Home / EndJump to min / max, when they are set.
EnterParses and clamps what you typed (with clampOnBlur); focus stays in the field.
EscReverts to the value the field had when it got focus.
Mouse wheelSteps only while the field has focus (Shift + wheel: largeStep), so the page still scrolls.
  • The field is an <input> with role="spinbutton", aria-valuenow, aria-valuemin, aria-valuemax and aria-valuetext set to the formatted value ("$1,250.00").
  • The − and + buttons are out of the tab order (keyboard users have the arrow keys), carry translatable aria-labels and aria-controls pointing at the field, and still step once when activated without a pointer.
  • label renders a real <label htmlFor>; without one, pass aria-label. description and message are linked with aria-describedby, and the message region is aria-live.
  • invalid (or an out-of-range value with clampOnBlur={false}) sets aria-invalid and the danger color.
  • name renders a hidden input with the raw number, so native forms submit 1250, not "1.250,00 €".

Motion

  • Focus: ring (3px tone ring, 200 ms), glow (soft tone halo, 300 ms) or lift (rises 2px). none is static.
  • Step: slide moves the value 7px in the step direction and fades it in over 170 ms; pop scales it to 1.09 and back over 220 ms. Both use the Web Animations API on the input, so React does not re-render for them.
  • Reduced motion: transitions and the step animation are skipped (checked with matchMedia at step time).
  • Press-and-hold runs one setTimeout chain (first repeat after 400 ms, then from about 126 ms down to 28 ms between steps) and stops on pointerup, pointercancel, window blur, reaching a bound or unmount.
  • Intl.NumberFormat instances are memoized on locale and the format object; pass a stable format object (a constant or useMemo) to avoid rebuilding them on every render.

Notes for AI

Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.

  • value and onValueChange are numbers (or null for empty), never strings. Do not parse the display text yourself.
  • Percent values are fractions: 0.15 shows as 15% and the user types 15 while editing.
  • Without locale the first render uses en-US and switches to the browser language after mount; pass locale when the text must not change after hydration.
  • Use format for currency, percent and units; do not put "$" in leading. Use trailing for plain words like "guests".
  • onValueChange fires while typing with the unclamped number, then again on blur with the clamped one. Validate on blur or submit, not on every keystroke.
  • Layers (variant, tone, size, shape, shadow, animation, stepAnimation, steppers) match the input component, so mixed forms look consistent.

FAQ

Why does the field show 1250 while I edit and 1.250,00 € after?

Editing a formatted string is error-prone, so the focused field shows the plain number in your locale and formats again on blur. The value prop never changes format.

Can I allow empty?

Yes. Clearing the field reports null; stepping from empty starts at min (or 0).

Does it work with react-hook-form?

Use it as a controlled field (Controller) with value and onValueChange, or pass name for native form submission of the raw number.

Related