Number Field
A numeric input with increment and decrement buttons and a scrub area.
Codeapp/demos/public/number-field.tsx
import { clientEntry, on, type Handle } from 'remix/component'
import { NumberField, NumberFieldScrubArea } from '@stemsui/registry/ui/number-field.tsx'
export const NumberFieldDemo = clientEntry(
import.meta.url,
function NumberFieldDemo(handle: Handle) {
let quantity: number | null = 1
let submitted: string | null = null
return () => (
<div class="flex w-full max-w-xs flex-col gap-8">
<NumberField
id="quantity"
value={quantity}
min={0}
max={20}
onValueChange={(value) => {
quantity = value
void handle.update()
}}
>
<label for="quantity" class="text-sm leading-none font-medium select-none">
Quantity{' '}
<span class="font-normal text-muted-foreground" data-testid="quantity-value">
({quantity ?? 'empty'})
</span>
</label>
</NumberField>
<NumberField
id="price"
defaultValue={1299.5}
min={0}
step={0.5}
format={{ style: 'currency', currency: 'EUR' }}
locale="de-DE"
allowWheelScrub
>
<NumberFieldScrubArea>
<label for="price">Price (drag me, de-DE)</label>
</NumberFieldScrubArea>
</NumberField>
<NumberField
id="discount"
defaultValue={0.15}
min={0}
max={1}
step={0.01}
format={{ style: 'percent' }}
>
<label for="discount" class="text-sm leading-none font-medium select-none">
Discount
</label>
</NumberField>
<NumberField aria-label="Disabled" defaultValue={42} disabled />
<form
class="flex flex-col gap-3 rounded-lg border p-4"
mix={on('submit', (event) => {
event.preventDefault()
let entries = [...new FormData(event.currentTarget).entries()]
submitted = entries.map(([key, value]) => `${key}=${String(value)}`).join('&')
void handle.update()
})}
>
<NumberField id="guests" name="guests" min={1} max={12} required>
<label for="guests" class="text-sm leading-none font-medium select-none">
Guests
</label>
</NumberField>
<button
type="submit"
class="inline-flex h-8 items-center justify-center self-start rounded-md bg-primary px-3 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Book
</button>
<p class="text-sm text-muted-foreground" data-testid="number-field-form-result">
Submitted: <code class="font-mono text-foreground">{submitted ?? '(not yet)'}</code>
</p>
</form>
</div>
)
},
)
Installation
npx shadcn add @stemsui/number-fieldRequires the one-time setup. Behavior: stems-ui/number-field.
API reference
The styled component is built on the headless module below: its props mirror these options, and the same events and data attributes apply.
stems-ui/number-field
number-field is a text input for numbers. It has stepper buttons, keyboard and wheel stepping,
an optional scrub area, and locale-aware formatting and parsing.
Base UI equivalent: Number Field.
Usage
import * as numberField from 'stems-ui/number-field'
function Quantity() {
return () => (
<numberField.Context id="qty" name="qty" defaultValue={1} min={0} max={99}>
<div mix={numberField.root()}>
<label for="qty">Quantity</label>
<div class="flex" mix={numberField.group()}>
<button mix={numberField.decrement()}>−</button>
<input mix={numberField.input()} />
<button mix={numberField.increment()}>+</button>
</div>
</div>
</numberField.Context>
)
}
stems-ui/number-field
<numberField.Context>props (Base UI'sNumberField.Rootprops):value/defaultValue:number | null.nullis an empty field.onValueChange(value, details):details.reasonis one of'input-change','input-clear','input-blur','input-paste','keyboard','increment-press','decrement-press','wheel', or'scrub'.detailsalso hasevent,trigger,direction(1or-1when stepping),cancel(), andisCanceled.
onValueCommitted(value, { reason, event }):- Fires on blur or Enter after typing.
- Fires on pointer release after pressing a stepper button or scrubbing (only when the value changed).
- Fires with each keyboard or wheel step.
min,max.step(1;'any'steps by 1 and turns off step validation),smallStep(0.1, Alt),largeStep(10, Shift and PageUp/PageDown).snapOnStep(false),allowOutOfRange(false).format(Intl.NumberFormatOptions),locale.allowWheelScrub(false),disabled,readOnly,required,name,form.id: the visible input's id, for<label for>.inputRef(input, signal): receives the hidden input.
numberField.root(): state attributes only.numberField.group():role="group".numberField.input(): use an<input>host. It sets:type="text",autocomplete="off",autocorrect="off",spellcheck="false", andaria-roledescription="Number field".inputmode:decimalwhenmin >= 0, andtextotherwise, because mobile decimal keypads have no minus key.value: the formatted value. While the user types, it keeps the raw text.disabled,readonly,required.
numberField.increment()/numberField.decrement():- Set
type="button"(on a<button>),tabindex="-1",aria-controls(the input), andaria-label("Increase"/"Decrease"; host props win). - Disabled at
max/min, and when the field is disabled or read-only.
- Set
numberField.scrubArea(options?): dragging the area changes the value. Options:direction:'horizontal'(default) or'vertical'.pixelSensitivity: pixels of movement per step (2).teleportDistance: how far the virtual cursor can move from the area's center before it wraps around.- Default
style(only when the host sets none):touch-action: none,user-select: none, and anew-resize/ns-resizecursor.
numberField.scrubAreaCursor(): a custom cursor element. It ishiddenunless the user is scrubbing with the pointer locked. It ownsposition: fixedand atransformthat follows the virtual cursor.- Events:
stems:number-field-change(NumberFieldChangeEvent:value,reason) andstems:number-field-commit(NumberFieldCommitEvent:value,reason). Both bubble from the input. Listen withonNumberFieldChange(handler)andonNumberFieldCommit(handler).
Behavior notes
- Typing:
- Each parseable edit changes the value right away (
'input-change'). - Emptying the field sets the value to
null('input-clear'). - Text that can't be parsed doesn't change the value. On blur, it reverts to the last valid value.
- Blur and Enter format the text, and clamp it to
min/maxunlessallowOutOfRangeis set. Enter doesn't prevent implicit form submission. The hidden input is updated before the submit.
- Each parseable edit changes the value right away (
- Keydown blocks characters that can't be part of a number. Allowed characters are digits
(including locale digits), separators, signs,
e,%, currency symbols, and the format's affixes. A paste is rejected unless every pasted character is allowed ('input-paste'). - Parsing (
lib/number.ts):- Uses the locale's group and decimal separators and digits.
- Accepts
.as the decimal separator when the locale doesn't group with it. - Strips currency, percent, and unit affixes. A percent format divides by 100, like
Intl.NumberFormatmultiplies. - Accepts a leading minus sign (any Unicode minus) or accounting parentheses.
- Display: without
format, the value is shown withmaximumFractionDigits: 20, so typed decimals aren't rounded to Intl's default of 3. - Keyboard:
- ArrowUp and ArrowDown step by
step. Hold Shift forlargeStepor Alt forsmallStep. - PageUp and PageDown step by
largeStep. - Home and End go to
minandmax, when they are set.
- ArrowUp and ArrowDown step by
- Stepping from an empty field starts from 0, clamped to
min/max. WithsnapOnStep, the next value is the next multiple of the step amount (from 0) in that direction. - Stepper buttons:
- A pointer press steps immediately. Holding it repeats after 400 ms, then every 60 ms. Release commits once.
- Keyboard or assistive-technology clicks step once.
- The buttons prevent
mousedown, so focus stays in the input.
- Wheel: only with
allowWheelScrub, and only while the input is focused and hovered. - Scrubbing:
- Mouse and pen use the Pointer Lock API when it is available (not in Safari, like Base UI). Otherwise the area uses pointer capture.
- The value follows
movementX(or-movementYfordirection: 'vertical'), one step perpixelSensitivitypixels. - The virtual cursor wraps around at the viewport edges, or at
teleportDistance. - Focus moves to the input.
- Form participation: the Context renders a visually hidden
<input type="number">after its children. It hasaria-hiddenandtabindex="-1", and carriesname,value,min,max,required, andstep, so native validation (valueMissing,rangeOverflow, ...) works.- The
stepattribute is only set whenminis set. Otherwise it isstep="any", like Base UI. - When validation focuses the hidden input, focus moves to the visible input.
- The
- Field: inside
<field.Context>the number field is the field's control. The visible input gets the field's control id (unlessidis set, which the field then follows), so<label mix={field.label()}>targets it, plusaria-describedbyandaria-invalid; it is focused when the field blocks a submission. The hidden<input type="number">gets the field'snameand is the validity element (valueMissing,rangeUnderflow,rangeOverflow,stepMismatch);validatereceives the number (ornull). On blur the typed text is committed before the field validates (validationMode="onBlur"). The field's (or an enclosingfieldset.Context's)disableddisables it. - Server rendering uses the runtime locale. Pass
localewhen the server and browser may differ.
Styling hooks
All parts (root, group, input, increment, decrement, scrubArea, scrubAreaCursor):
| Attribute | Values |
|---|---|
data-disabled | Present when the field is disabled. On the stepper buttons, it is also present at max / min. |
data-readonly | Present when the field is read-only. |
data-required | Present when the field is required. |
data-scrubbing | Present while scrubbing. |
data-valid / data-invalid | In a field: validated and valid / invalid. |
data-dirty | In a field: the value changed from the initial value. |
data-touched | In a field: the input lost focus once. |
data-filled | In a field: the value is not null. |
data-focused | In a field: the input has focus. |
No CSS variables are set.