Number Field
A numeric input with stepper buttons, keyboard step, and pointer scrubbing.
Built on Base UI NumberField, which handles clamping and locale-aware
formatting. Use it whenever the value is a number within a sensible range.
Import
import { NumberField } from "@photon-ai/pho-ui/components/number-field";
Basic
Set defaultValue (or value), plus min / max / step on
NumberField.Root. Compose the Group with a Decrement, Input, and
Increment. Typed and stepped values clamp to the range.
import { IconMinus, IconPlus } from "@tabler/icons-react";
<NumberField.Root defaultValue={3} min={0} max={10}>
<NumberField.Group>
<NumberField.Decrement aria-label="Decrease">
<IconMinus />
</NumberField.Decrement>
<NumberField.Input />
<NumberField.Increment aria-label="Increase">
<IconPlus />
</NumberField.Increment>
</NumberField.Group>
</NumberField.Root>;
Formatting
Pass format — a standard Intl.NumberFormatOptions object — to render the
value as currency, a percentage, or with grouping separators. The Input still
edits the raw number; formatting is applied on display and on blur. locale
overrides the runtime locale.
<NumberField.Root
defaultValue={1500}
step={100}
min={0}
format={{ style: "currency", currency: "USD" }}
>
<NumberField.Group>
<NumberField.Decrement aria-label="Decrease">
<IconMinus />
</NumberField.Decrement>
<NumberField.Input className="w-28" />
<NumberField.Increment aria-label="Increase">
<IconPlus />
</NumberField.Increment>
</NumberField.Group>
</NumberField.Root>
Scrubbing
Wrap a label in NumberField.ScrubArea to make it a drag handle — press and
drag horizontally to change the value, the way a design tool adjusts opacity or
size. Add NumberField.ScrubAreaCursor to show a virtual cursor while the
pointer is locked. Point the label’s htmlFor at the input’s id so a click
still focuses the field.
import { IconArrowsLeftRight } from "@tabler/icons-react";
<NumberField.Root id={id} defaultValue={50} min={0} max={100}>
<NumberField.ScrubArea>
<label
htmlFor={id}
className="text-pho-secondary cursor-ew-resize text-base"
>
Opacity
</label>
<NumberField.ScrubAreaCursor>
<IconArrowsLeftRight className="size-4" />
</NumberField.ScrubAreaCursor>
</NumberField.ScrubArea>
<NumberField.Group>{/* Decrement / Input / Increment */}</NumberField.Group>
</NumberField.Root>;
Disabled and read-only
Set disabled on NumberField.Root to make the whole field inert — the input
and both steppers ignore interaction and dim. Set readOnly to keep the value
focusable and selectable but not editable; typing and stepping do nothing while
the field stays in the Tab order.
<NumberField.Root defaultValue={3} min={0} max={10} disabled>
{/* Group / Decrement / Input / Increment */}
</NumberField.Root>
<NumberField.Root defaultValue={3} min={0} max={10} readOnly>
{/* Group / Decrement / Input / Increment */}
</NumberField.Root>
Anatomy
- NumberField.Root — owns
value,min,max,step, and formatting - NumberField.Group — the bordered surface around the parts
- NumberField.Decrement / .Increment — stepper buttons
- NumberField.Input — the editable value
- NumberField.ScrubArea — optional drag-to-change region
- NumberField.ScrubAreaCursor — optional virtual cursor shown while scrubbing
Props
Set on NumberField.Root:
- value —
number | null— the controlled numeric value - defaultValue —
number— uncontrolled initial value - onValueChange —
(value, eventDetails) => void— fires as the value changes - onValueCommitted —
(value, eventDetails) => void— fires when the value is committed (blur, or pointer release after scrub/press) - min / max —
number— clamp range for steps and typed values - step —
number | "any"(default1) — increment for buttons, arrow keys, and scrub - smallStep —
number(default0.1) — step while the Alt key is held - largeStep —
number(default10) — step while the Shift key is held - snapOnStep —
boolean(defaultfalse) — snap to multiples of the step - allowOutOfRange —
boolean(defaultfalse) — let typed values exceedmin/maxwithout clamping - allowWheelScrub —
boolean(defaultfalse) — scrub with the mouse wheel while focused - format —
Intl.NumberFormatOptions— display formatting (currency, percent, grouping) - locale —
Intl.LocalesArgument— locale for formatting; defaults to the runtime locale - disabled —
boolean(defaultfalse) — ignore all interaction - readOnly —
boolean(defaultfalse) — allow focus but not editing - required —
boolean(defaultfalse) — value required before form submit - name —
string— form field name for the hidden input - id —
string— id applied to the input element
Set on NumberField.ScrubArea:
- direction —
horizontal·vertical(defaulthorizontal) — axis the cursor scrubs along - pixelSensitivity —
number(default2) — pixels of movement per value change - teleportDistance —
number— distance before the locked cursor loops back to center
Accessibility
NumberField.Input renders a native <input> (exposed as a textbox) with
inputMode="numeric" and aria-roledescription="Number field"; it reflects
field validity through aria-invalid. The role description is not a name — give
the field an accessible name with a Field.Label or an aria-label.
The stepper buttons ship with default aria-labels ("Increase" /
"Decrease") and aria-controls pointing at the input, and they carry
tabIndex={-1} so they stay out of the Tab order — keyboard users change the
value from the input itself. They remain reachable to touch screen readers.
Keyboard (focus in the input):
- Arrow Up / Arrow Down — step by
step - Shift + Arrow — step by
largeStep - Alt/Option + Arrow — step by
smallStep - Home — jump to
min(whenminis set) - End — jump to
max(whenmaxis set)
Best practices
- Prefer a number field over a plain
Inputwhenever the value is numeric — it brings steppers, clamping, keyboard step, and number-pad keyboards for free. - Always set
min/maxso the steppers and typed values stay in range, and soHome/Endcan jump to the bounds. - The steppers are already labelled (
"Increase"/"Decrease") and skipped by Tab; override thearia-labels only to localize or clarify, not to re-add what’s already there. - Use
formatfor currency and percentages instead of decorating the value yourself — the input keeps editing the raw number and formats on blur.