NumberField
Numeric form input with stacked, split, inline or no step buttons. Field-shell parity — label placements, errorMessage swap, on-color tone — plus locale-aware Intl formatting, modifier stepping, and a polite step announcer.
pnpm add @tessinaui/uiWhen to use
- An exact number the person types or steps. Quantities, seats, limits and amounts, where the value matters and the step buttons, arrow keys or the
scrublabel drag all reach it. - A bounded value. Set
minandmax; the matching step button stops at the bound and stays reachable by assistive technology. - A number with a unit or currency.
formathandles currency, percent and compact values, andprefixandsuffixcarry the symbol or unit. - A value people adjust from where it is. Small changes to a sensible
defaultValuetake a few presses;largeStepcovers the bigger jumps. - A visible range. State the allowed range in
supportingText(for example "Between 1 and 10"), because stepping and a non-defaultstepreject out-of-range input silently otherwise. - A narrow field. Width signals the expected magnitude, so a quantity from 1 to 10 in a full-width field mis-signals.
When not to use
- For tap-only counters such as guests or cart lines. Use Stepper, or a Popover with a Stepper.
- For a small set of choices such as one to five tickets. Use Select.
- When an approximate value is enough and position on a range helps. Use Slider.
- When the value reaches far from where it starts. Stepping to a distant number takes many presses; a typed field without step buttons, or a slider, fits better.
- For identifiers that only look numeric, such as phone, card or account numbers. They are strings, not quantities: use a Field text input.
- For digit-per-box codes. Use OtpInput.
- For a number with a unit picker ("30 minutes" with a choice of unit). Compose Field with
FieldDropdown. - For a full-screen amount entry with a custom keypad. That is a screen pattern, not a field.
Installation
pnpm add @tessinaui/uiUsage
import { NumberField } from "@tessinaui/ui";{/* Basic uncontrolled */}
<NumberField label="Quantity" defaultValue={0} />
{/* Controlled with bounds */}
<NumberField label="Seats" value={qty} onValueChange={setQty} min={0} max={99} />
{/* Currency input with locale formatting */}
<NumberField
label="Price"
defaultValue={1299.99}
step={0.01}
format={{ style: "currency", currency: "USD" }}
locale="en-US"
/>
{/* Validation — errorMessage replaces supportingText and implies intent="error" */}
<NumberField
label="Seats"
required
supportingText="Up to 10 seats"
errorMessage={tooMany ? "Your plan allows at most 10 seats" : undefined}
/>
{/* On a coloured plate — tone resolves from the surrounding Surface */}
<Surface background="primary" className="p-6">
<NumberField label="Quantity" defaultValue={1} />
</Surface>Notes
- Percent formats store fractions: with
format={{ style: "percent" }}, a displayed50%isvalue={0.5}— setstep={0.001}-scale steps or arrows jump 100 percentage points. - Empty-field stepping: with no value, neither button is disabled; the
first step lands on
0clamped into range. - Give every field a sensible
defaultValuewhere one exists — don't leave number inputs blank. - SSR: pass an explicit
locale— the server and client can format differently and the mismatch is only suppressed, not fixed. - Wheel:
allowWheelScrubrequires focus, so a scrolling page never edits a field in passing. - Form submission:
namesubmits via a visually hidden<input type="number">sibling; the visible input is text.
Examples
Default
A labelled numeric input with stacked stepper buttons.
Buttons placement
Step buttons can be stacked, split, inline, or hidden with none —
keyboard stepping keeps working without buttons (the enterprise-settings shape).
Rolling digits and hold-to-repeat
Each digit rolls on its own wheel, in the direction of the change: up rolls up,
down rolls down, and a wrap (9 to 0) rolls on rather than spinning back. The real
input stays underneath for the caret, selection and assistive tech; typing hands
the surface back to it. Holding a step button repeats after 400ms and ramps from
150ms to 40ms per step; onValueCommitted fires once, on release. Reduced motion
changes the value at once.
Limits
Pressing past min or max is answered, not ignored. The number strains a few
pixels toward the press and springs home — further on pushes in a row, capped —
the refused button shakes once, and the bound ("Max 10") rises in beside the label
and is announced politely. With reduced motion the number takes a brief warning
tint instead of the strain and shake.
Scrub
scrub turns the label into a drag handle: slide it sideways to change the value
(toward the inline end adds). Shift drags by largeStep, Alt by smallStep, and
vertical scrolling keeps working on touch. Pair it with a visible label; buttons
can be hidden.
Underline variant
variant="underline" draws the Field family's bottom-rule shell: square
whatever rounded says, focus shown as an inset 2px bottom band in the intent
colour instead of the ring, and no fill in any state — disabled and read-only
dim the rule rather than growing a well. The stepper seams stay: they divide
the segments, not the shell.
Sizes
Five sizes from xs to xl. Type follows the input family's ladder
(12/14/14/16/16 on desktop) and floors at 16px on phones so iOS never zooms.
Intents
Border, focus ring, and supporting text follow error, warning, success,
info. Warning helper text uses the tinted foreground — raw amber fails AA.
Validation
errorMessage replaces supportingText while present (a single-slot
swap), joins aria-describedby, marks the field invalid, and
announces politely. required and optional render their markers.
Label placement
labelPlacement="top" (default), inside, or start — logical, so start
sits right in RTL.
Prefix / suffix
In-well adornments for currency and units. Give symbols a spoken name when the
glyph alone is ambiguous (aria-label on the field, or a unit suffix in text).
Amount with Max action
The suffix slot accepts interactive content — the fintech "Max" pattern is a
ghost Button inside it.
Presets + custom amount
Preset chips beside a free field; largeStep gives PageUp/PageDown a bigger
stride.
On-color
On a coloured Surface the well, buttons, label and helper inks derive from
the surface ink contract — no prop needed inside a Surface.
States
Disabled swaps tokens; read-only stays focusable, readable, and suppresses intent paint — and read-only beats disabled when both are set.
Locale-aware formatting
format renders currency, percent, or compact values via Intl.NumberFormat.
Skeleton
RTL
dir="rtl" mirrors the chrome around the value: prefix moves right, suffix left, and the steppers keep their logical order, with decrement at the inline start and increment at the end. The value itself keeps its digit order, and locale decides which numerals render, not dir. Leave dir unset and the field follows the page's direction.
API Reference
Props
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | — | Controlled value (null = empty) |
defaultValue | number | — | Uncontrolled initial value |
min / max | number | — | Bounds. The matching button disables at a bound (aria-disabled, stays AT-reachable). Also enables Home/End |
step | number | "any" | 1 | Amount per increment / decrement |
smallStep | number | 0.1 | Step while Alt is held |
largeStep | number | 10 | Step while Shift is held, and for PageUp / PageDown |
snapOnStep | boolean | false | Snap to the nearest step multiple on step |
allowOutOfRange | boolean | false | Typed text may exceed min/max (stepping still clamps) |
format | Intl.NumberFormatOptions | — | Locale-aware formatting — also defines which characters parse |
locale | Intl.LocalesArgument | runtime | BCP-47 locale tag. Set it for SSR — server/client locales can differ |
allowWheelScrub | boolean | false | Wheel steps the value while the input is focused and hovered |
scrub | boolean | false | Drag the label sideways to change the value. Needs a visible label |
onValueChange | (value, details) => void | — | Every change; details.reason discriminates typing / stepping / wheel |
onValueCommitted | (value, details) => void | — | Settled value on blur / release / keyboard commit |
label | ReactNode | — | Visible label |
labelPlacement | "top" | "inside" | "start" | "top" | Logical label placement |
labelWidth | "sm" | "md" | "lg" | "xl" | "auto" | CSS length | "md" | Width of the start-label column, so sibling fields in a form share one control column — sm 6rem · md 8rem · lg 11rem · xl 14rem. Labels wrap, never truncate. Only applies with labelPlacement="start", from sm up |
required / optional | boolean | false | Asterisk / "(optional)" marker. Mutually exclusive — required wins |
visuallyHiddenLabel | boolean | false | Keep the label for AT, hide it visually |
infoText | string | — | Info line beside the label, joined into aria-describedby |
supportingText | ReactNode | — | Helper below the field, coloured by intent |
errorMessage | ReactNode | — | Replaces supportingText, implies intent="error", sets aria-invalid, announces politely |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Border, ring and helper colour |
variant | "outline" | "filled" | "underline" | "outline" | Shell: bordered box, tinted well (no outline until focus or an intent) or bottom rule. Underline ignores rounded, focuses with an inset band instead of the ring, and paints no fill in any state |
tone | "default" | "on-color" | surface | On-color well recipe for coloured plates; inherits from Surface |
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Density. Type reads the input-family ladder |
rounded | "none" | "sm" | "md" | "lg" | "full" | "full" | Corner cascade |
buttonsPlacement | "stacked" | "split" | "inline" | "none" | "stacked" | Step-button layout |
incrementLabel / decrementLabel | string | "Increase" / "Decrease", Arabic under dir="rtl" | Accessible names for the step buttons |
prefix / suffix | ReactNode | — | In-well adornments (interactive content allowed) |
placeholder | string | — | Placeholder when empty |
name | string | — | HTML form name (submitted via the hidden number input) |
disabled / readOnly | boolean | false | Read-only beats disabled; read-only stays focusable and readable |
dir | "ltr" | "rtl" | inherited | Text direction |
strings | Partial<NumberFieldStrings> | English, Arabic under dir="rtl" | Override the optional marker, the step buttons' default names (increment, decrement) and the bound note's lead-in (min, max). incrementLabel / decrementLabel win |
id | string | auto | id of the underlying input |
inputRef | Ref<HTMLInputElement> | — | Ref to the visible input |
inputMode | string | auto | Leave unset — Base UI picks numeric/decimal, and on iOS falls back to text when min allows negatives (the numeric pad has no minus key) |
className | string | — | Class on the outer wrapper |
inputClassName | string | — | Class on the <input>. The rolling digits take it too, so style the value's padding, alignment and ink here |
containerClassName | string | — | Class on the bordered container |
Forwarding (§7): aria-* props land on the input; everything else
(data-*, test ids, handlers) lands on the wrapper.
Deprecated: labelPosition ("outside-top"→top, "outside-left"→start)
and wrapperClassName (use className; the old input-styling className job
moved to inputClassName). Both alias with a dev warning for one minor.
Migration (from ≤ the previous minor)
| Old | New |
|---|---|
labelPosition="outside-top" | labelPlacement="top" |
labelPosition="outside-left" | labelPlacement="start" |
wrapperClassName="…" | className="…" |
className styling the input | inputClassName |
| Step buttons in the Tab order | One tab stop — buttons are tabIndex={-1}, still getByRole("button")-queryable |
Keyboard
| Key | Action |
|---|---|
↑ / ↓ | Step by step |
Shift + ↑/↓ | Step by largeStep |
Alt + ↑/↓ | Step by smallStep |
Page Up / Page Down | Step by largeStep (viewport scroll is prevented) |
Home / End | Jump to min / max — only when that bound is set |
| Typing | Standard text editing; disallowed characters are ignored |
Accessibility
- Text-input model, not
role="spinbutton"— the input istype="text"witharia-roledescription="Number field"(Base UI's model: VoiceOver has documented spinbutton bugs, and iOS suppressesrequiredannouncements under a role description). The real gap that model leaves — a pointer press on a step button changes the value with focus elsewhere — is closed by a polite step announcer: button, wheel, scrub and keyboard steps announce the settled formatted value from an sr-onlyrole="status"region (debounced, so press-and-hold announces once). - One tab stop. The input carries the whole keyboard path; the step buttons
are
tabIndex={-1}with real accessible names, so touch screen-reader users can still reach and activate them (the APG pattern, and the wider consensus). - Described-by chain:
supportingText,errorMessageandinfoTextare real elements joined into the input'saria-describedby; a consumeraria-describedbyis merged, not clobbered. - At a bound the matching button gets
aria-disabled+data-disabled(not the native attribute) — style hooks aredata-[disabled], never:disabled.
| AT | Status |
|---|---|
| VoiceOver (macOS, Safari) | Passed 2026-09-14. Focus reads "1, Quantity, Number field"; Up reads "2", "3", Down "2"; the steppers read "Increase, button" |
| NVDA / JAWS | UNTESTED — cannot run on this machine |