Components

Number Field

A number input with buttons to step it, after Base UI's NumberField and the APG spinbutton. Without format it is a native <input type="number">, which steps, validates and submits without JavaScript; with format (Intl.NumberFormat options: a currency, a percentage, grouping) it shows the formatted number, reads what is typed back in the same locale, and a hidden input submits the number. <lu-number-field>'s 2.6 kB script adds Page Up and Page Down, Home and End, press-and-hold buttons, a scrub area, the wheel when asked, and commit-time clamping. bind:value keeps a signal in sync.

A live demo, with the default theme. Its code
ts
import { NumberField } from "@lumiose/ui"; // or "@lumiose/ui/number-field"
import "@lumiose/ui/number-field.css"; // or "@lumiose/ui/styles.css" for every component
import "@lumiose/ui/themes/default.css"; // optional: the default theme

#Anatomy

tsx
<NumberField.Root>
  <NumberField.ScrubArea />
  <NumberField.Group>
    <NumberField.Decrement />
    <NumberField.Input />
    <NumberField.Increment />
  </NumberField.Group>
</NumberField.Root>
Part Renders
Root <lu-number-field> with the range and format as attributes, and an <input type="hidden"> that submits the number with format
ScrubArea <span data-scrub>: dragging across it steps the number (usually around the label)
Group <div> around the input and the buttons
Input <input type="number" inputmode min max step>, or with format <input type="text" role="spinbutton" aria-value…>
Increment <button type="button" tabindex="-1" aria-label="Increase" aria-controls="…">, disabled at max
Decrement <button type="button" tabindex="-1" aria-label="Decrease" aria-controls="…">, disabled at min

Name the field with a Field (Field.Label), a <label for> the Root's id, or an aria-label on the Input.

#API

#NumberField.Root

Prop Type Default
value number | null Shows this number (null: empty) when it changes. Pair it with onValueChange.
defaultValue number | null null The number at first; a form reset goes back to it.
onValueChange (value: number | null) => void Called as the number changes: typing, a key, a button, the wheel, a drag.
onValueCommitted (value: number | null) => void Called when a change is done: the input left, a key pressed, a button or a drag released.
bind:value Signal<number | null> Stands in for value and onValueChange.
min, max number The range. A typed number outside it becomes its end when committed.
step number 1 The step of the arrow keys, the buttons, the wheel and a drag.
largeStep number 10 The step of Page Up, Page Down, and Shift with an arrow key or a button.
smallStep number 0.1 The step of Alt with an arrow key or a button.
snapOnStep boolean false A step lands on the next multiple of its size (from min, or 0): 1.3 steps up to 2, not 2.3.
format Intl.NumberFormatOptions How the number is shown; with it, the input is a text field and a hidden input submits the number. Options or a locale the browser refuses fall back to its defaults.
locale string the browser's The locale of the formatted number (the server's in server HTML).
allowWheelScrub boolean false The wheel steps the number while the input has focus and the pointer is over it.
allowOutOfRange boolean false Typed numbers outside the range stay as typed, for the browser's validation to report.
name, form string The name the number is submitted under, and the id of its form when it is outside it.
id string generated The input's id, for a <label for>.
required, disabled, readOnly boolean false As on a native input; read-only and disabled fields' buttons are disabled.

#NumberField.ScrubArea

Prop Type Default
direction "horizontal" | "vertical" "horizontal" The drag that changes the number: right goes up, or up does.
pixelSensitivity number 2 How many pixels of drag make one step.

#The other parts

Group takes <div> props. Input takes <input> props except the range's own (type, value, min, max, step, name, required, readonly, id: the Root's). Increment and Decrement take <button> props; give them an aria-label in the page's language. Inside a Field, the input takes the field's id, name, description and error.

Native or formatted. A native number input cannot show grouping, a currency or a percentage, and reads a typed number in the browser's own way, so a formatted field is a text field with the spinbutton role, and the number it submits sits in a hidden input. The native input is the default because it needs no JavaScript: typing, the arrow keys, min, max and step validation and the form value are the browser's.

With snapOnStep, each kind of step snaps to its own size: Shift steps 37 up to 40, Alt steps 1.37 up to 1.4. A native field (no format) validates the value against step without JavaScript; the script sets its step to any, so the values Alt's small step, Home, End and clamping give always submit.

The keyboard: inputmode="decimal" when min is 0 or more, "text" otherwise, since iOS's decimal keyboard has no minus sign. Digits of other scripts (full-width 1, Arabic-Indic ٤) are read as numbers.

Differences from Base UI's NumberField. No ScrubAreaCursor: Base UI draws a cursor because its scrub area locks the pointer (which hides the real one); this scrub area keeps the real cursor (a resize cursor), so a drag stops at the screen's edge. No inputRef, no render. locale is a string, written to the element for its script.

#Example

Live at the top of the page: an island
tsx
import { NumberField } from "@lumiose/ui";
import { signal } from "lumiose";

export function PriceField() {
  const price = signal<number | null>(19.9);
  return (
    <NumberField.Root
      id="price"
      name="price"
      bind:value={price}
      min={0}
      step={0.5}
      format={{ style: "currency", currency: "EUR" }}
      class="lu-number-field"
    >
      <NumberField.ScrubArea>
        <label for="price">Price</label>
      </NumberField.ScrubArea>
      <NumberField.Group>
        <NumberField.Decrement aria-label="Lower the price">−</NumberField.Decrement>
        <NumberField.Input />
        <NumberField.Increment aria-label="Raise the price">+</NumberField.Increment>
      </NumberField.Group>
    </NumberField.Root>
  );
}

#Styling

With the default theme, put lu-number-field on Root: the label above one bordered group, the input centred between two 36px buttons that dim at the ends of the range, and a focus ring around the whole group.

With your own CSS, style the parts through class, the input's :focus-visible, the buttons' :disabled and [data-action="increment"] / [data-action="decrement"], the scrub area's [data-scrub], and lu-number-field[data-scrubbing] while a drag is on.

@lumiose/ui/number-field.css (0.2 kB brotli) hides the native spinners once the script runs (:defined) and hides the buttons until then, keeps held buttons from selecting text, and gives a scrub area its resize cursor and touch-action: none.

#Keyboard and focus

Key Does
Arrow Up / Arrow Down One step up or down.
Shift + Arrow Up / Down largeStep up or down.
Alt + Arrow Up / Down smallStep up or down.
Page Up / Page Down largeStep up or down.
Home / End min or max, when set.
Enter Commits the typed number (clamped, formatted); a form submits.
Tab Moves on: one tab stop, the input (the buttons are not in the tab order).

Right to left, dragging the scrub area toward the left steps up, as a slider runs. A press on a button steps once (by largeStep with Shift, smallStep with Alt) and, with a mouse, puts focus in the input, so the arrow keys go on stepping (a touch leaves focus where it is, so no virtual keyboard opens); holding it steps again every 60 ms after 400 ms. A click from a keyboard or a screen reader on a button steps once. Every drag (the scrub area) has the arrow keys as its alternative (WCAG 2.5.7). The wheel steps the number only with allowWheelScrub, while the input has focus and the pointer is over it; otherwise the wheel scrolls the page, as over any field (Chromium's own stepping of a focused number input is turned off). Ctrl with the wheel zooms the page, and a sideways swipe never steps. Text that is no number (Firefox lets letters into a number input) goes on commit, and the last number comes back.

#Without JavaScript, and the element's script

Without format, the native number input works and submits with no script; the buttons, which need it, stay hidden and the native spinners show. With format, the text input submits its text as typed until the script moves the name to the hidden input, so a form that must work without JavaScript should leave format out.

<lu-number-field>'s script (2.6 kB brotli) adds the large step, Home and End, the buttons, the scrub area and the wheel, clamps and formats on commit, keeps the hidden input, aria-valuenow and aria-valuetext current, and follows a form reset.

#Browser support

Native number inputs and Intl.NumberFormat (with formatToParts): every supported browser. Scrubbing uses pointer capture, without pointer lock.

  • Field: a label, a description and errors for a number field.
  • Slider: a number picked by dragging along a range.
  • Input: any other text.