NumberInput
<NumberInput /> is the DS's canonical numeric form control — a drop-in replacement for
<Input type="number"> that fixes its long list of UX defects (scientific notation, silent
rejection on blur, no thousand separator, vendor spinners) and ships typed number | null
values, locale-aware formatting + parsing, clamping + precision rounding, mouse-hold
accelerating step, full keyboard control, and a hidden <input type="hidden"> for HTML
form submission with the canonical value.
Overview — labeled inputs with steppers and constraints
Why a separate component
Native type="number" accepts arbitrary scientific notation (1e5), silently rejects bad input
on blur, lacks formatting (no thousand separators), and renders ugly browser-vendored spinners.
NumberInput owns all of that:
- Typed value (
number | null) —nullmeans the input is empty; consumers never deal with"42"strings. - Render as
<input type="text">under the hood +role="spinbutton"+aria-valuemin/aria-valuemax/aria-valuenow/aria-valuetext. - Locale-aware parsing (
Intl.NumberFormatprobes + Arabic-Indic / Persian digit normalization) handles1,234.56,1.234,56, and١٬٢٣٤٫٥٦uniformly. - Cached
Intl.NumberFormatinstances — formatting on hot keystrokes stays cheap. - Mouse-hold stepper with an accelerating repeat rate (
useStepperHold) — released for the next consumer that needs press-and-hold semantics. - Hidden
<input type="hidden">for HTML form submission with the canonical numeric string.
Inherits Input's visual story
NumberInput re-uses inputRecipe + inputInnerRecipe directly — same variants (outline,
solid, ghost, underline), same sizes (sm / md / lg), same 7-color palette, same
controlBase focus ring. Adding a new Input variant or color picks up here automatically. The
only new visual surface is the stepper button group (stepperGroupRecipe + stepperButtonRecipe)
which lives next to the component.
Stepper position
stepperPosition | Layout |
|---|---|
'end' (default) | Vertically stacked + (top) / − (bottom) on the logical end. |
'start' | Same stacked layout on the logical start. |
'split' | Single − on the logical start, single + on the logical end. |
All positions use logical CSS properties, so RTL layouts flip automatically.
Keyboard
| Key | Action |
|---|---|
| Arrow Up / Down | value ± step |
| Shift + Arrow | value ± largeStep (defaults to step * 10) |
| PageUp / PageDown | value ± largeStep |
| Home | value = min (when min defined) |
| End | value = max (when max defined) |
| Enter | Commit + format display |
| Escape | Revert to last committed value |
The stepper buttons are intentionally not in the tab order (tabIndex={-1}) — keyboard users
operate on the input itself. Pointer-only users get the buttons.
Examples
Overview
Basic — `number | null` value, default stepper
Min / max / step
step + largeStep (Shift+Arrow)
Currency — Intl format pass-through
Percentage — Intl percent style
Precision — decimal-place rounding
Locale: de-DE — 1.234,56 display
Locale: ar-EG — Arabic-Indic digits
Scroll-wheel opt-in
Stepper positions — end / start / split
Variants — inherits Input
Sizes — sm / md / lg
Colors — 7-role palette
Hide stepper buttons
Disabled + read-only
Invalid — aria-invalid + danger ring
With label + helper text
Form submission — hidden canonical value
Accessibility
- Visible input is
<input type="text" role="spinbutton">with the full ARIA value triad. aria-valuetextcarries the formatted display string so screen readers announce"$1,234.56"instead of the raw1234.56.- Stepper buttons have translatable
aria-labels (incrementLabel/decrementLabel) and aretabIndex={-1}so they don't pollute the tab order. - Wires through
useFormFieldA11y— invalid + required + describedby flow exactly like Input. - Dev-only warning fires once when no
aria-label/aria-labelledby/ wrapping<label>is found (same shape as Input'sINPUT_NO_LABEL).