DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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-default steps 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 needUse
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 helpsSlider
A full-screen amount entry with a custom keypadA 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

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
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
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"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
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>
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, matching React Aria's: 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 (APG/Carbon/Fluent 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)Pending manual pass — badge stays beta until done
NVDA / JAWSUNTESTED — cannot run on this machine

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 (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: 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.
Navigation MenuOTP Input

On this page

PlaygroundInstallationUsageWhen to useExamplesDefaultButtons placementSizesIntentsValidationLabel placementPrefix / suffixAmount with Max actionPresets + custom amountOn-colorStatesLocale-aware formattingSkeletonAPI ReferencePropsMigration (from ≤ the previous minor)KeyboardAccessibilityNotes