Skip to content
misoto22 design

Forms

NumberField

A number, typed or swept to.

Usage

When to reach for it

The number has a range and a sensible increment. A bare quantity is an Input with type="number"; a value judged by WHERE it sits on a track is a Slider.
TSX
import { NumberField } from '@misoto22/design'

Notes

A number, typed or swept to.

Input with type="number" is a box that happens to reject letters. This is the control for a number that has a RANGE and a sensible increment: the arrows step it, the grip sweeps it, and min, max and step are honoured on the way out rather than merely announced.

Reach for Slider instead when the position along the range is the information — a volume, a confidence, anything a reader judges by where the thumb sits. Reach for this when the digits are. A slider that also has to be exact is a Slider with editable, not one of these beside it.

Clamping happens when the field is left, not while it is being typed. A minimum of 10 would otherwise make 50 unreachable: the 5 is clamped up to 10 before the 0 arrives. So onValueChange can report a number outside the range mid-keystroke, and the value that settles is always inside it.

The native spinner buttons are hidden — they are three different controls in three browsers, and none of them is this system's. The grip replaces them for a pointer; the arrow keys were always the keyboard's answer.

Anatomy

NumberField anatomy
ElementDescription
FieldrequiredA native number input wearing CONTROL_BASE, so it is the same box as Input, Textarea and Select — same padding, same focus, same disabled opacity. The native spinner buttons are hidden: they are three different controls in three browsers and none of them is this system’s.
Scrub gripA horizontal-arrows glyph at the inline start, on unless scrub is false. Dragging it changes the value by one step every 4px, ten steps with Shift held, and it follows the reading direction — in an RTL page, more is to the left. Pointer only and aria-hidden, because the keyboard already has the arrow keys.
Unitunit, drawn inside the end of the box and announced through aria-describedby. The slot is a fixed 3rem, so a unit longer than about four characters runs under a long number.

Best practices

Do

  • Reach for it when a value is TUNED rather than entered — a duration, a line height, an offset. The grip is the whole argument for this over an Input: a reader finds those by sweeping past the neighbouring values, not by typing candidates one at a time.
  • Wrap it in a Field. The root is a div and the id lands on the input inside it, so the label binds and clicks through exactly as it does for an Input — but only if there is a Field to do it.
  • Pass min, max and step. They are what the arrows step by, what one notch of a scrub is worth, and what the value is reconciled with when the field is left; without them the control is an Input with a grip on it.

Don’t

  • Do not expect the range to hold mid-keystroke. Clamping happens on blur, not on every character, because a minimum of 10 otherwise makes 50 unreachable — the 5 is clamped up before the 0 arrives. onValueChange can report a number outside the range; the value that SETTLES is always inside it.
  • Do not turn the grip off and expect a pointer to have another way through. There are no spinner buttons behind it — hiding those is the point — so scrub={false} leaves a mouse with typing and nothing else. Turn it off for a quantity that is chosen rather than swept to, and accept that trade knowingly.
  • Do not put the unit in the box and nowhere else past about four characters. The slot is fixed, so “requests” runs under the number; a long unit belongs in the Field’s label, where it is read rather than clipped.

Examples

default

The grip at the start of each field is the reason to reach for this rather than an Input: drag it and the value sweeps through its neighbours, one step every few pixels, ten steps at a time with Shift held. That is how a line height or a radius is actually found — by passing through the wrong answers until one looks right, not by typing candidates one at a time. The arrows do the same journey from the keyboard, which is why the grip is not announced.

Between 1 and 3.

px

clamping on the way out

The range is applied when the field is left, not on every keystroke. It has to be: with a minimum of 10, clamping as you type makes 50 unreachable — the 5 is pushed up to 10 before the 0 arrives. So the live figure below can sit outside the range for as long as somebody is mid-number, and the value that settles never does. Type 4, then tab away.

Between 10 and 100.

Reported: 30

Keyboard

NumberField keyboard interactions
KeyDoes
Steps by one step, honouring min and max.
EnterReconciles what has been typed with the range and the step.
EscapeAbandons the edit and restores the last settled value.

Accessibility

  • It is a real <input type="number">, so the platform supplies the spinbutton role, the value, and the range it is announced against.
  • unit reaches assistive tech through aria-describedby, so “300” is not announced as a number with no dimension.
  • The grip is aria-hidden and not focusable: it commits nothing a keyboard cannot already reach, and announcing it would offer a reader a control that does nothing when they press it.
  • invalid and aria-invalid are read together, so a form library setting either one paints the same border.