Stems UI
Menu

Number Field

A numeric input with increment and decrement buttons and a scrub area.

Submitted: (not yet)

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-field

Requires 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's NumberField.Root props):
    • value / defaultValue: number | null. null is an empty field.
    • onValueChange(value, details):
      • details.reason is one of 'input-change', 'input-clear', 'input-blur', 'input-paste', 'keyboard', 'increment-press', 'decrement-press', 'wheel', or 'scrub'.
      • details also has event, trigger, direction (1 or -1 when stepping), cancel(), and isCanceled.
    • 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", and aria-roledescription="Number field".
    • inputmode: decimal when min >= 0, and text otherwise, 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), and aria-label ("Increase" / "Decrease"; host props win).
    • Disabled at max / min, and when the field is disabled or read-only.
  • 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 an ew-resize / ns-resize cursor.
  • numberField.scrubAreaCursor(): a custom cursor element. It is hidden unless the user is scrubbing with the pointer locked. It owns position: fixed and a transform that follows the virtual cursor.
  • Events: stems:number-field-change (NumberFieldChangeEvent: value, reason) and stems:number-field-commit (NumberFieldCommitEvent: value, reason). Both bubble from the input. Listen with onNumberFieldChange(handler) and onNumberFieldCommit(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/max unless allowOutOfRange is set. Enter doesn't prevent implicit form submission. The hidden input is updated before the submit.
  • 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.NumberFormat multiplies.
    • Accepts a leading minus sign (any Unicode minus) or accounting parentheses.
  • Display: without format, the value is shown with maximumFractionDigits: 20, so typed decimals aren't rounded to Intl's default of 3.
  • Keyboard:
    • ArrowUp and ArrowDown step by step. Hold Shift for largeStep or Alt for smallStep.
    • PageUp and PageDown step by largeStep.
    • Home and End go to min and max, when they are set.
  • Stepping from an empty field starts from 0, clamped to min/max. With snapOnStep, 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 -movementY for direction: 'vertical'), one step per pixelSensitivity pixels.
    • 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 has aria-hidden and tabindex="-1", and carries name, value, min, max, required, and step, so native validation (valueMissing, rangeOverflow, ...) works.
    • The step attribute is only set when min is set. Otherwise it is step="any", like Base UI.
    • When validation focuses the hidden input, focus moves to the visible input.
  • Field: inside <field.Context> the number field is the field's control. The visible input gets the field's control id (unless id is set, which the field then follows), so <label mix={field.label()}> targets it, plus aria-describedby and aria-invalid; it is focused when the field blocks a submission. The hidden <input type="number"> gets the field's name and is the validity element (valueMissing, rangeUnderflow, rangeOverflow, stepMismatch); validate receives the number (or null). On blur the typed text is committed before the field validates (validationMode="onBlur"). The field's (or an enclosing fieldset.Context's) disabled disables it.
  • Server rendering uses the runtime locale. Pass locale when the server and browser may differ.

Styling hooks

All parts (root, group, input, increment, decrement, scrubArea, scrubAreaCursor):

AttributeValues
data-disabledPresent when the field is disabled. On the stepper buttons, it is also present at max / min.
data-readonlyPresent when the field is read-only.
data-requiredPresent when the field is required.
data-scrubbingPresent while scrubbing.
data-valid / data-invalidIn a field: validated and valid / invalid.
data-dirtyIn a field: the value changed from the initial value.
data-touchedIn a field: the input lost focus once.
data-filledIn a field: the value is not null.
data-focusedIn a field: the input has focus.

No CSS variables are set.