DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
CheckboxRadioSwitchTextareaNumberFieldOTP InputSliderCircularSliderRatingSelectPickerComboboxSearchSearchSuggestTokenizerSegmentedControl
AI agentsContributing
  1. Components
  2. NumberField

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/ui

When 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 scrub label drag all reach it.
  • A bounded value. Set min and max; the matching step button stops at the bound and stays reachable by assistive technology.
  • A number with a unit or currency. format handles currency, percent and compact values, and prefix and suffix carry the symbol or unit.
  • A value people adjust from where it is. Small changes to a sensible defaultValue take a few presses; largeStep covers the bigger jumps.
  • A visible range. State the allowed range in supportingText (for example "Between 1 and 10"), because stepping and a non-default step reject 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

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { NumberField } from "@tessinaui/ui";
example.tsx
{/* 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 displayed 50% is value={0.5} — set step={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 0 clamped into range.
  • Give every field a sensible defaultValue where 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: allowWheelScrub requires focus, so a scrolling page never edits a field in passing.
  • Form submission: name submits 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

PropTypeDefaultDescription
valuenumber | null—Controlled value (null = empty)
defaultValuenumber—Uncontrolled initial value
min / maxnumber—Bounds. The matching button disables at a bound (aria-disabled, stays AT-reachable). Also enables Home/End
stepnumber | "any"1Amount per increment / decrement
smallStepnumber0.1Step while Alt is held
largeStepnumber10Step while Shift is held, and for PageUp / PageDown
snapOnStepbooleanfalseSnap to the nearest step multiple on step
allowOutOfRangebooleanfalseTyped text may exceed min/max (stepping still clamps)
formatIntl.NumberFormatOptions—Locale-aware formatting — also defines which characters parse
localeIntl.LocalesArgumentruntimeBCP-47 locale tag. Set it for SSR — server/client locales can differ
allowWheelScrubbooleanfalseWheel steps the value while the input is focused and hovered
scrubbooleanfalseDrag 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
labelReactNode—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 / optionalbooleanfalseAsterisk / "(optional)" marker. Mutually exclusive — required wins
visuallyHiddenLabelbooleanfalseKeep the label for AT, hide it visually
infoTextstring—Info line beside the label, joined into aria-describedby
supportingTextReactNode—Helper below the field, coloured by intent
errorMessageReactNode—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"surfaceOn-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 / decrementLabelstring"Increase" / "Decrease", Arabic under dir="rtl"Accessible names for the step buttons
prefix / suffixReactNode—In-well adornments (interactive content allowed)
placeholderstring—Placeholder when empty
namestring—HTML form name (submitted via the hidden number input)
disabled / readOnlybooleanfalseRead-only beats disabled; read-only stays focusable and readable
dir"ltr" | "rtl"inheritedText direction
stringsPartial<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
idstringautoid of the underlying input
inputRefRef<HTMLInputElement>—Ref to the visible input
inputModestringautoLeave unset — Base UI picks numeric/decimal, and on iOS falls back to text when min allows negatives (the numeric pad has no minus key)
classNamestring—Class on the outer wrapper
inputClassNamestring—Class on the <input>. The rolling digits take it too, so style the value's padding, alignment and ink here
containerClassNamestring—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)

OldNew
labelPosition="outside-top"labelPlacement="top"
labelPosition="outside-left"labelPlacement="start"
wrapperClassName="…"className="…"
className styling the inputinputClassName
Step buttons in the Tab orderOne tab stop — buttons are tabIndex={-1}, still getByRole("button")-queryable

Keyboard

KeyAction
↑ / ↓Step by step
Shift + ↑/↓Step by largeStep
Alt + ↑/↓Step by smallStep
Page Up / Page DownStep by largeStep (viewport scroll is prevented)
Home / EndJump to min / max — only when that bound is set
TypingStandard text editing; disallowed characters are ignored

Accessibility

  • Text-input model, not role="spinbutton" — the input is type="text" with aria-roledescription="Number field" (Base UI's model: VoiceOver has documented spinbutton bugs, and iOS suppresses required announcements 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-only role="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, errorMessage and infoText are real elements joined into the input's aria-describedby; a consumer aria-describedby is merged, not clobbered.
  • At a bound the matching button gets aria-disabled + data-disabled (not the native attribute) — style hooks are data-[disabled], never :disabled.
ATStatus
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 / JAWSUNTESTED — cannot run on this machine
TextareaOTP Input

On this page

When to useWhen not to useInstallationUsageNotesExamplesDefaultButtons placementRolling digits and hold-to-repeatLimitsScrubUnderline variantSizesIntentsValidationLabel placementPrefix / suffixAmount with Max actionPresets + custom amountOn-colorStatesLocale-aware formattingSkeletonRTLAPI ReferencePropsMigration (from ≤ the previous minor)KeyboardAccessibility