flyingsalmon

Number Field

A quantity field that formats for the reader's locale, clamps to its bounds, and steps from the keyboard or its own spin buttons. It reports a number or nothing at all, never the text on screen.

It reports a number, never a string

onValueChange carries number | null. An empty field is null — never NaN, never 0, so nothing downstream has to tell "unanswered" from "zero". While you type, the field reports what the parser reads, in range or not; blur and Enter clamp into [min, max] and reformat. Text the parser cannot read reverts to the last committed value and raises no error: the error prop is the only error channel. Native onChange still reaches the inner input if you want the keystrokes.

Always set min

Set min on every field that cannot go negative. It bounds the clamp, it gives Home somewhere to jump, it disables the decrease button at the floor, and it picks the mobile keyboard: numeric when min is zero or above, decimal when the step is fractional, and the full text keyboard only when negatives are possible. A guest count with no min is a field that accepts minus three people.

Group size

The plainest shape: a floor of one, a step of one, no prefix and no unit. The decrease button dims at the floor and stops there, and so do the arrow keys.

Light
Dark

Budget per person

prefix puts a symbol before the value and step sizes one press of a spin button. Both are plain text — no Intl currency formatting — so the prefix reads the way your product writes money. The value still groups for the locale, and the prefix joins it in the announcement: $1,500.

Light
Dark

Duration

unit puts a word after the value. With min and max both set, Home and End jump to the ends of the range, Page Up and Page Down move by largeStep — ten times step unless you say otherwise — and each spin button dims as its end arrives.

Light
Dark

Sizes

Two sizes match input's, so a number field and a text field line up in a row. The spin buttons are squares the height of the field, so the control keeps its proportions at both sizes. There is no variant prop and no width prop — constrain the wrapper with className, as every example here does.

Light
Dark

Error

error turns the border, the dividers, and the label destructive, sets aria-invalid, and renders the message below. The field grows downward only. Nothing shakes — the mood is soft, so errors arrive calmly.

Light

Book for at least one guest

Enter a budget above zero

Dark

Book for at least one guest

Enter a budget above zero

Loading

loading replaces the spin button pair with a spinner in exactly the pair's width, so nothing moves. The field stays typeable — loading here means background work, such as a quote being priced, and the submit button is what locks a flow. With an error alongside, both show and the spinner turns destructive.

Light

No trips at this budget

Dark

No trips at this budget

Disabled and read-only

Both keep the spin buttons in place and mark them aria-disabled, so the control never changes shape as it locks. Disabled dims the field and its label together, takes no pointer events, and posts nothing, exactly like a native disabled control. Read-only keeps full contrast on a muted background, stays focusable so the value can be read and copied, and still posts.

Light
Dark

Keyboard

Up and Down step by step. Page Up, Page Down, and the shifted arrows step by largeStep. Home and End jump to min and max, and do nothing when that bound is unset. Enter commits and lets the form submit. Escape is left alone, because it belongs to whatever dialog encloses the field, and the wheel never steps, so a scroll through a form cannot change an answer. Tab reaches the field and then leaves: the spin buttons sit outside the tab ring, since the arrow keys already do their job.

Motion

The border, the field background, and the spin button backgrounds transition at --motion-fast. Holding a spin button repeats after 400ms at 60ms. The error message is the one enter and exit: height and opacity on spring-settle. Digits never tween and the reformat on blur is instant — a number that animates is a number you cannot read.

Accessibility

The text input is the spinbutton: it carries aria-valuemin, aria-valuemax, aria-valuenow, and an aria-valuetext that includes the prefix and the unit, so a screen reader hears "$1,500" rather than "1500". An empty field omits both value attributes rather than announce a number it does not have. The visible prefix and unit are aria-hidden, because the value text already says them. The spin buttons are named Decrease and Increase and point at the input with aria-controls. The whole box draws the focus ring on focus-within, since the input inside it owns no border of its own. required marks the label and sets aria-required; the validation itself stays with your app.