# NumberField

A number, typed or swept to.

- Group: Forms
- Import: `import { NumberField } from '@misoto22/design'`
- Page: https://ui.misoto22.com/components/number-field/
- Related: input, slider, field

## 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.

## Anatomy

- **Field** (required) — A 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 grip** — A 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.
- **Unit** — unit, 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.

## 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.

## Keyboard

- ↑ / ↓ — Steps by one step, honouring min and max.
- Enter — Reconciles what has been typed with the range and the step.
- Escape — Abandons the edit and restores the last settled value.

## NumberField

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.

### Props

- `value` — `number`.
- `defaultValue` — `number`.
- `onValueChange` — `(value: number) => void`. Fires with the parsed number. Raw `onChange` still fires with the event.
- `min` — `number` default `Number.NEGATIVE_INFINITY`.
- `max` — `number` default `Number.POSITIVE_INFINITY`.
- `step` — `number` default `1`. The increment for the arrows and for one notch of a scrub.
- `unit` — `string`. A unit drawn inside the end of the field — `%`, `px`, `ms`. The field reserves a fixed slot for it, so keep it to about four characters; a longer one runs under a long number. It is announced through `aria-describedby` as well as drawn, because a unit nobody hears turns "300" into a number with no dimension.
- `scrub` — `boolean` default `true`. A grip at the start of the field that changes the value as it is dragged. On by default, and the reason to reach for this over `<Input type="number">`: a value that is being TUNED — a duration, a weight, an offset — is found by sweeping through neighbouring values, not by typing candidates one at a time. Shift multiplies the travel by ten. Pointer only, and `aria-hidden` for that reason: the same journey on a keyboard is the arrow keys on the field itself, which the platform already provides. Turn it off where the number is a quantity rather than a setting — a line item's quantity is chosen, not swept to.
- `invalid` — `boolean`. Paints the resting border with `--danger` and reflects `aria-invalid`.
- `ref` — `Ref<HTMLInputElement>`.

Also accepts: `Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'defaultValue' | 'min' | 'max' | 'step' | 'type'>`.

## Example — default

```tsx
import { Field, NumberField } from '@misoto22/design'

<div className="flex w-full max-w-xs flex-col gap-5">
  <Field label="Line height" hint="Between 1 and 3.">
    <NumberField defaultValue={1.5} min={1} max={3} step={0.1} />
  </Field>
  <Field label="Corner radius">
    <NumberField defaultValue={12} min={0} max={64} unit="px" />
  </Field>
</div>
```

## Example — clamping on the way out

```tsx
import { Field, NumberField, Text } from '@misoto22/design'

<div className="flex w-full max-w-xs flex-col gap-3">
  <Field label="Retries" hint="Between 10 and 100.">
    <NumberField value={retries} onValueChange={setRetries} min={10} max={100} step={5} />
  </Field>
  <Text size="sm" tone="muted">
    Reported: {retries}
  </Text>
</div>
```
