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.
Playground
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>When to use
- The user types or steps an exact number — quantities, seats, limits, amounts.
- State the allowed range in
supportingText(e.g. "Between 1 and 10") — steppers and non-defaultsteps reject silently otherwise (Carbon's rule). - Keep numeric fields narrow — width signals expected magnitude (HIG). A 1–10 quantity in a full-width field mis-signals.
Reach for something else when:
| You need | Use |
|---|---|
| Tap-only counter rows (guests, cart lines) | Stepper (or Popover + Stepper) |
| A small bounded set of choices (1–5 tickets) | Select |
| An imprecise value where position-on-range helps | Slider |
| A full-screen amount entry with a custom keypad | A screen pattern, not a field |
| Digit-per-box entry (codes) | OtpInput |
| A number with a unit picker ("30 [minutes ▾]") | Field + FieldDropdown |
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).
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 (the M3/Carbon
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
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 |
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 |
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" | 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 |
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> |
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, matching React Aria's: 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 (APG/Carbon/Fluent 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) | Pending manual pass — badge stays beta until done |
| NVDA / JAWS | UNTESTED — cannot run on this machine |
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 (Carbon: 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.