# Number Input (/docs/number-input)





`NumberInput` is a numeric `<input>` with spin buttons, built on Ark UI's NumberInput machine and the same shell as [`Input`](/docs/input). It clamps to a range, formats with `Intl.NumberFormat`, supports the mouse wheel, and ships the full keyboard model (`↑` / `↓` step, `PageUp` / `PageDown` jump, `Home` / `End` to min / max).

```tsx
import { NumberInput } from '@42-central/ui-react/number-input';

<NumberInput defaultValue={3} min={0} max={10} />
```

The value is a real `number` (or `null` for empty) — the machine speaks strings internally, but `value` / `defaultValue` take a `number | null` and `onChange` hands one back, `null` whenever the field has no valid number. Like the rest of the family it carries its own `label`, `description`, `error`, and `required` — no external wrapper needed — and passing `error` flips it to the invalid (red) state. Omit them and it still composes cleanly inside a [`Field`](/docs/field).

## Playground [#playground]

<story.Playground />

## Min, max & step [#min-max--step]

`min` / `max` clamp the range; `step` sets the spin amount. With `allowMouseWheel`, scrolling over a focused input nudges the value too.

<story.Preview variant="Min / max / step" />

## Formatting [#formatting]

`formatOptions` is forwarded straight to `Intl.NumberFormat`, so currency, percent, and unit display come for free — the parsed value stays a plain number.

<story.Preview variant="Currency" />

## Sizes [#sizes]

Five heights — `xs` through `xl` — matching the rest of the family. The control font, padding, and the steppers scale to match.

<Preview align="block" code="&#x22;<NumberInput size=\&#x22;xs\&#x22; defaultValue={1} />\n<NumberInput size=\&#x22;sm\&#x22; defaultValue={1} />\n<NumberInput size=\&#x22;md\&#x22; defaultValue={1} />\n<NumberInput size=\&#x22;lg\&#x22; defaultValue={1} />\n<NumberInput size=\&#x22;xl\&#x22; defaultValue={1} />&#x22;">
  <div className="flex w-full max-w-[16rem] flex-col gap-3">
    <NumberInput size="xs" defaultValue="1" />

    <NumberInput size="sm" defaultValue="1" />

    <NumberInput size="md" defaultValue="1" />

    <NumberInput size="lg" defaultValue="1" />

    <NumberInput size="xl" defaultValue="1" />
  </div>
</Preview>

## No controls [#no-controls]

`hideControls` drops the steppers for a plain numeric field — useful inside dense forms where the spin buttons would crowd the layout.

<story.Preview variant="No controls" />

## Clearable [#clearable]

`clearable` adds a clear (×) button once there's a value — the numeric analogue of [`Select`](/docs/select)'s `clearable`. Clicking it resets the field to `null`. `placeholder` shows while the field is empty.

<story.Preview variant="Clearable" />

## Colors [#colors]

Like the rest of the form family, the input is palette-independent throughout — including the focus halo, which is a fixed `brand-500` per the 42 UI Kit spec. `color` is accepted for consistency with the rest of the kit but tints nothing here. See [Colors](/docs/colors) and [Focus](/docs/focus).

<Preview align="block" code="&#x22;<NumberInput color=\&#x22;brand\&#x22; defaultValue={1} />\n<NumberInput color=\&#x22;green\&#x22; defaultValue={1} />\n<NumberInput color=\&#x22;purple\&#x22; defaultValue={1} />&#x22;">
  <div className="flex w-full max-w-[16rem] flex-col gap-3">
    <NumberInput color="brand" defaultValue="1" />

    <NumberInput color="green" defaultValue="1" />

    <NumberInput color="purple" defaultValue="1" />
  </div>
</Preview>

## Invalid & disabled [#invalid--disabled]

`invalid` swaps the border and ring to red regardless of `color` (and sets `aria-invalid`). `disabled` dims the shell and blocks input; `readOnly` keeps full contrast but rejects edits.

<Preview align="block" code="&#x22;<NumberInput invalid defaultValue={5} />\n<NumberInput disabled defaultValue={5} />\n<NumberInput readOnly defaultValue={5} />&#x22;">
  <div className="flex w-full max-w-[16rem] flex-col gap-3">
    <NumberInput defaultValue="5" />

    <NumberInput defaultValue="5" />

    <NumberInput defaultValue="5" />
  </div>
</Preview>

## With label, description & error [#with-label-description--error]

`label`, `description` (helper text), and `error` are built in via [`Field`](/docs/field) — no external wrapper needed. Passing `error` flips the shell to the invalid (red) state; `required` adds the semantic flag and a red asterisk on the label.

<Preview align="block" code="&#x22;<NumberInput label=\&#x22;Slots\&#x22; description=\&#x22;How many evaluation slots to book.\&#x22; defaultValue={1} min={1} />\n\n<NumberInput label=\&#x22;Slots\&#x22; required defaultValue={1} min={1} />\n\n<NumberInput label=\&#x22;Slots\&#x22; error=\&#x22;Pick at least one\&#x22; defaultValue={0} min={1} />&#x22;">
  <div className="flex w-full max-w-[16rem] flex-col gap-5">
    <NumberInput label="Slots" description="How many evaluation slots to book." defaultValue="1" min="1" />

    <NumberInput label="Slots" defaultValue="1" min="1" />

    <NumberInput label="Slots" error="Pick at least one" defaultValue="0" min="1" />
  </div>
</Preview>

If you omit these props, the control still composes inside an outer [`Field`](/docs/field), inheriting the field's `invalid`, `disabled`, and `readOnly` from context — so an `error` on the field flips the input red automatically.

## Controlled [#controlled]

Drive the value from state. `onChange` hands you a `number`, or `null` while the field is empty or mid-edit.

```tsx
'use client';
import { useState } from 'react';
import { NumberInput } from '@42-central/ui-react/number-input';

export function Slots() {
  const [value, setValue] = useState<number | null>(1);
  return <NumberInput value={value} onChange={setValue} clearable min={1} max={99} />;
}
```

## Forms [#forms]

A `name` wires the underlying input into native `<form>` submission and `FormData` — no controlled state required.

```tsx
<form action={save}>
  <NumberInput name="slots" defaultValue={1} min={1} />
</form>
```

## Translations [#translations]

`translations` reuses Ark's own NumberInput keys — the stepper names and `valueText`, which sets the input's `aria-valuetext` — plus the combobox family's `clearTriggerLabel`. Unset keys keep their English default.

```tsx
<NumberInput
  clearable
  translations={{
    incrementLabel: 'Augmenter',
    decrementLabel: 'Diminuer',
    clearTriggerLabel: 'Effacer',
    valueText: (value) => `${value} crédits`,
  }}
/>
```

## API [#api]

<TypeTable
  type="{
  value: { type: 'number | null', description: 'Controlled value; null for empty.' },
  defaultValue: { type: 'number | null', description: 'Uncontrolled initial value; null for empty.' },
  onChange: { type: '(value: number | null) => void', description: 'Fires with the next numeric value, or null when the field has no valid number.' },
  min: { type: 'number', description: 'Minimum value.' },
  max: { type: 'number', description: 'Maximum value.' },
  step: { type: 'number', default: '1', description: 'Increment / decrement step.' },
  placeholder: { type: 'string', description: 'Placeholder shown while the field is empty.' },
  formatOptions: { type: 'Intl.NumberFormatOptions', description: 'Forwarded to Intl.NumberFormat for display.' },
  allowMouseWheel: { type: 'boolean', default: 'false', description: 'Let the mouse wheel change the value while focused.' },
  clampValueOnBlur: { type: 'boolean', default: 'true', description: 'Clamp into [min, max] on blur.' },
  hideControls: { type: 'boolean', default: 'false', description: 'Hide the increment / decrement steppers.' },
  clearable: { type: 'boolean', default: 'false', description: &#x22;Show a clear button once there's a value, resetting to null.&#x22; },
  name: { type: 'string', description: 'Name for native form submission.' },
  invalid: { type: 'boolean', default: 'false', description: 'Red border + ring; sets aria-invalid.' },
  disabled: { type: 'boolean', default: 'false', description: 'Disable the control.' },
  readOnly: { type: 'boolean', default: 'false', description: 'Focusable and full-contrast, but rejects edits.' },
  required: { type: 'boolean', default: 'false', description: 'Required flag + asterisk on the label.' },
  label: { type: 'ReactNode', description: 'Field label above the control.' },
  description: { type: 'ReactNode', description: 'Field helper text below the control.' },
  error: { type: 'ReactNode', description: 'Field error; sets the invalid (red) state.' },
  variant: { type: '&#x22;default&#x22; | &#x22;unstyled&#x22;', default: '&#x22;default&#x22;', description: 'Shell treatment.' },
  size: { type: '&#x22;xs&#x22; | &#x22;sm&#x22; | &#x22;md&#x22; | &#x22;lg&#x22; | &#x22;xl&#x22;', default: '&#x22;md&#x22;', description: 'Height, padding, control font, steppers.' },
  color: { type: 'Color', default: '&#x22;brand&#x22;', description: 'Focus-ring accent.' },
  startSlot: { type: 'ReactNode', description: 'Inline-start adornment (icon, prefix).' },
  suffix: { type: 'ReactNode', description: 'Non-interactive unit hint (e.g. &#x22;h&#x22;, &#x22;%&#x22;) rendered right after the value, before the clear button / steppers.' },
  translations: {
    type: '{ incrementLabel?: string; decrementLabel?: string; clearTriggerLabel?: string; valueText?: (value: string) => string }',
    description: &#x22;Accessible names of the steppers (defaults \&#x22;Increment\&#x22; / \&#x22;Decrement\&#x22;) and the clear button (default \&#x22;Clear value\&#x22;), plus valueText for the input's aria-valuetext. Ark's own NumberInput keys plus the combobox family's clearTriggerLabel. A partial override keeps the other defaults.&#x22;,
  },
  classNames: { type: '{ root?, input?, control?, field?, label? }', description: 'Per-part class overrides.' },
  testId: { type: 'string', description: 'data-testid on the inner <input>, not the shell. See the Testing guide.' },
}"
/>

## Accessibility [#accessibility]

* Full NumberInput keyboard model from Ark: `↑` / `↓` step, `PageUp` / `PageDown` jump by a larger step, `Home` / `End` snap to min / max.
* The input is `role="spinbutton"` with `aria-valuemin` / `aria-valuemax` / `aria-valuenow` / `aria-valuetext` wired from the machine.
* `invalid` sets `aria-invalid`. Pass `label` / `description` / `error` for an accessible name and error text via the built-in [`Field`](/docs/field), or compose inside an outer `Field` instead.
* The steppers are real `<button>`s with `Increment` / `Decrement` labels; the wheel and scrub gestures are progressive enhancements over keyboard / typing.
* `suffix` is decorative only — `aria-hidden` and non-selectable, never part of the editable value or the accessible name. To show a compact unit hint without a visible label, keep passing `label` (it stays the accessible name) and visually hide it with `classNames={{ label: "sr-only" }}`.
