# Input number

A number field: typing, the arrow keys and stepper buttons change a number shown in the reader's locale.

- **Import:** `import { InputNumber } from "@booleanpress/ui/input-number"`
- **Also install:** `@base-ui/react`
- **APG Spinbutton:** <https://www.w3.org/WAI/ARIA/apg/patterns/spinbutton/>
- **Page:** <https://ui.booleanpress.com/components/input-number> · @booleanpress/ui 0.2.0

## Usage

`InputNumber` is [Base UI's Number Field](https://base-ui.com/react/components/number-field) with the field look. It needs the `@base-ui/react` peer package, which only products that import `@booleanpress/ui/input-number` install. Name it with a `Label` whose `htmlFor` is its `id`.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export function SendLimit() {
  return (
    <>
      <Label htmlFor="send-limit">Daily send limit</Label>
      <InputNumber id="send-limit" defaultValue={500} min={0} buttons="stacked" />
    </>
  )
}
```

It is uncontrolled with `defaultValue`, or controlled with `value` and `onValueChange`; the value is a number, or `null` when the field is empty. `onValueCommitted` is called once a change is final: on blur after typing, or when a stepper button is released. The number is shown in the provider's `locale` (`1,234.5` in English, `1.234,5` in German); `locale` sets another for one field. `format` takes `Intl.NumberFormat` options: `{ style: "currency", currency: "EUR" }`, `{ maximumFractionDigits: 0 }`, `{ useGrouping: false }`, or `{ style: "unit", unit: "kilogram" }` for a unit read as part of the value. `min` and `max` bound the value: the arrows, the buttons, Home and End stop there, and typed text is brought back into range on blur. `step` (1) is what an arrow or a button adds, `largeStep` (10) what Page Up, Page Down or Shift with an arrow adds, `smallStep` (0.1) what Alt with an arrow adds.

`buttons` adds stepper buttons: `stacked` at the end, `horizontal` on both sides, `vertical` above and below. `prefix` and `suffix` draw text inside the field, such as a unit; they are read as the field's description. The field is as wide as its content; `fluid` fills the container. `size` is `sm` (28 px), `default` (35 px) or `lg` (42 px), and `variant="filled"` fills the field grey; both default to the provider's `controlSize` and `fieldVariant`. `className` goes on the outer box; the other attributes (`placeholder`, `aria-*`, `onBlur`) go on the input. With `name`, the number is submitted with its form, and a reset of the form puts an uncontrolled field back to its `defaultValue`.

## Examples

### Basic

A labelled field with a number in thousands, grouped in the provider's locale.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberBasic() {
  return (
    <div className="flex flex-col gap-2">
      <Label htmlFor="monthly-quota">Monthly email quota</Label>
      <InputNumber id="monthly-quota" defaultValue={42723} />
    </div>
  )
}
```

### Decimals

`format` sets the digits: a whole number, a number without grouping, and one with two to five decimals.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberDecimals() {
  return (
    <div className="flex w-full max-w-72 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="batch-size">Batch size</Label>
        <InputNumber id="batch-size" fluid defaultValue={42723} format={{ maximumFractionDigits: 0 }} />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="ticket-number">Ticket number, without grouping</Label>
        <InputNumber id="ticket-number" fluid defaultValue={58151} format={{ useGrouping: false }} />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="response-hours">Average response, in hours</Label>
        <InputNumber
          id="response-hours"
          fluid
          defaultValue={2351.35}
          format={{ minimumFractionDigits: 2, maximumFractionDigits: 5 }}
        />
      </div>
    </div>
  )
}
```

### Locale

The same number in English, German and Indian grouping, each field inside its own `BooleanUIProvider locale`.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
import { BooleanUIProvider } from "@booleanpress/ui/provider"

const decimals = { minimumFractionDigits: 2 }

export default function InputNumberLocale() {
  return (
    <div className="flex w-full max-w-72 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="revenue-us">United States</Label>
        <BooleanUIProvider locale="en-US">
          <InputNumber id="revenue-us" fluid defaultValue={115744} format={decimals} />
        </BooleanUIProvider>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="revenue-de">Germany</Label>
        <BooleanUIProvider locale="de-DE">
          <InputNumber id="revenue-de" fluid defaultValue={635524} format={decimals} />
        </BooleanUIProvider>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="revenue-in">India</Label>
        <BooleanUIProvider locale="en-IN">
          <InputNumber id="revenue-in" fluid defaultValue={732762} format={decimals} />
        </BooleanUIProvider>
      </div>
    </div>
  )
}
```

### Currency

`format={{ style: "currency" }}` with US dollars, euros, rupees and yen, each in its own locale.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
import { BooleanUIProvider } from "@booleanpress/ui/provider"

export default function InputNumberCurrency() {
  return (
    <div className="flex w-full max-w-72 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="plan-usd">Plan price, US dollars</Label>
        <BooleanUIProvider locale="en-US">
          <InputNumber id="plan-usd" fluid defaultValue={1500} format={{ style: "currency", currency: "USD" }} />
        </BooleanUIProvider>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="plan-eur">Plan price, euros</Label>
        <BooleanUIProvider locale="de-DE">
          <InputNumber id="plan-eur" fluid defaultValue={2500} format={{ style: "currency", currency: "EUR" }} />
        </BooleanUIProvider>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="plan-inr">Plan price, rupees</Label>
        <BooleanUIProvider locale="en-IN">
          <InputNumber
            id="plan-inr"
            fluid
            defaultValue={4250}
            format={{ style: "currency", currency: "INR", currencyDisplay: "code" }}
          />
        </BooleanUIProvider>
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="plan-jpy">Plan price, yen</Label>
        <BooleanUIProvider locale="ja-JP">
          <InputNumber id="plan-jpy" fluid defaultValue={5002} format={{ style: "currency", currency: "JPY" }} />
        </BooleanUIProvider>
      </div>
    </div>
  )
}
```

### Prefix and suffix

A unit through `format`, a `%` and an "Expires in" `prefix`, and an "emails an hour" `suffix` at the field's end.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberPrefixSuffix() {
  return (
    <div className="flex w-full max-w-72 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="parcel-weight">Parcel weight</Label>
        <InputNumber id="parcel-weight" fluid defaultValue={20} format={{ style: "unit", unit: "kilogram" }} />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="discount">Discount</Label>
        <InputNumber id="discount" fluid defaultValue={50} min={0} max={100} prefix="%" />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="key-expiry">API key expiry</Label>
        <InputNumber
          id="key-expiry"
          fluid
          defaultValue={10}
          prefix="Expires in"
          format={{ style: "unit", unit: "day", unitDisplay: "long" }}
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="send-rate">Sending rate</Label>
        <InputNumber id="send-rate" fluid defaultValue={120} suffix="emails an hour" />
      </div>
    </div>
  )
}
```

### Buttons

`buttons="stacked"` puts an up and a down button at the end, inside the field's edge.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberButtons() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="seat-price">Seat price</Label>
        <InputNumber
          id="seat-price"
          fluid
          buttons="stacked"
          defaultValue={20}
          format={{ style: "currency", currency: "USD" }}
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="team-seats">Team seats, 0 to 100</Label>
        <InputNumber id="team-seats" fluid buttons="stacked" defaultValue={25} min={0} max={100} />
      </div>
    </div>
  )
}
```

### Buttons on both sides

`buttons="horizontal"` puts minus before the field and plus after it, each in the field's edge.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberButtonsHorizontal() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="credit-top-up">Credit top-up</Label>
      <InputNumber
        id="credit-top-up"
        fluid
        buttons="horizontal"
        defaultValue={10.25}
        step={0.25}
        locale="de-DE"
        format={{ style: "currency", currency: "EUR" }}
      />
    </div>
  )
}
```

### Vertical

`buttons="vertical"` puts the up button above a narrow field and the down button below.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberVertical() {
  return (
    <div className="flex flex-col items-center gap-2">
      <Label htmlFor="retry-attempts">Retries</Label>
      <InputNumber id="retry-attempts" className="w-10" buttons="vertical" defaultValue={3} min={0} max={10} />
    </div>
  )
}
```

### Min and max

`min` and `max` stop the arrows and buttons; Home and End go to them, and the button at a bound turns off.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberMinMax() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="bounce-threshold">Bounce threshold, 0 to 100</Label>
      <InputNumber
        id="bounce-threshold"
        fluid
        buttons="stacked"
        defaultValue={95}
        min={0}
        max={100}
        aria-describedby="bounce-threshold-hint"
      />
      <p id="bounce-threshold-hint" className="text-xs text-muted-foreground">
        Home goes to 0 and End to 100; at either end its button turns off.
      </p>
    </div>
  )
}
```

### Step

`step={15}` for the arrows and buttons, `largeStep={60}` for Page Up and Page Down.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberStep() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="retry-interval">Retry interval, in seconds</Label>
      <InputNumber
        id="retry-interval"
        fluid
        buttons="stacked"
        defaultValue={30}
        min={0}
        step={15}
        largeStep={60}
        aria-describedby="retry-interval-hint"
      />
      <p id="retry-interval-hint" className="text-xs text-muted-foreground">
        The arrows step by 15; Page Up, Page Down and Shift with an arrow by 60.
      </p>
    </div>
  )
}
```

### With float label

Inside a `FloatLabel`, over the field and on its edge.

```tsx
import { FloatLabel } from "@booleanpress/ui/float-label"
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberFloatLabel() {
  return (
    <div className="flex flex-wrap items-end gap-4 pt-4.5">
      <FloatLabel>
        <InputNumber id="fl-over-limit" />
        <Label htmlFor="fl-over-limit">Daily limit</Label>
      </FloatLabel>
      <FloatLabel variant="on">
        <InputNumber id="fl-on-price" format={{ style: "currency", currency: "USD" }} />
        <Label htmlFor="fl-on-price">Price</Label>
      </FloatLabel>
    </div>
  )
}
```

### Sizes

`sm` is 28 px tall with 12 px text and icons, `default` 35 px with 14 px, `lg` 42 px with 16 px.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberSizes() {
  return (
    <div className="flex flex-col items-center gap-4">
      <InputNumber size="sm" aria-label="Small" placeholder="Small" buttons="stacked" />
      <InputNumber aria-label="Normal" placeholder="Normal" buttons="stacked" />
      <InputNumber size="lg" aria-label="Large" placeholder="Large" buttons="stacked" />
    </div>
  )
}
```

### Filled

`variant="filled"` draws the grey `--field-filled` fill, which stays on hover and focus.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberFilled() {
  return <InputNumber variant="filled" aria-label="Daily limit" placeholder="Daily limit" />
}
```

### Disabled

Neither the text nor the buttons can be used, and the number is not submitted.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberDisabled() {
  return (
    <InputNumber disabled aria-label="Discount" defaultValue={50} format={{ style: "unit", unit: "percent" }} buttons="stacked" />
  )
}
```

### Invalid

`aria-invalid` draws the error edge and a red placeholder.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberInvalid() {
  return (
    <div className="flex flex-wrap justify-center gap-4">
      <InputNumber aria-invalid aria-label="Amount" placeholder="Amount" />
      <InputNumber aria-invalid aria-label="Amount, filled" placeholder="Amount" variant="filled" />
    </div>
  )
}
```

### Read-only

`readOnly` shows the number, which cannot change; the field can still be focused and copied.

```tsx
import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberReadOnly() {
  return (
    <InputNumber readOnly aria-label="Emails sent this month" defaultValue={8420} buttons="stacked" />
  )
}
```

## Accessibility

**Semantics.** A native text `input` with `inputmode="decimal"` (or `numeric`), described as a number field through `aria-roledescription` (the provider string `numberFieldRole`). The stepper buttons are native `button`s outside the tab order, named by the provider strings `increment` and `decrement`, and disabled at a bound. A hidden native number input carries the value for forms.

**Labels.** Name it with a `Label` whose `htmlFor` is the field's `id`, or `aria-label`. A prefix and a suffix are added to the field's description, so a unit such as "emails an hour" is read after the value. Put hints and errors in `aria-describedby`.

**Focus.** The field is one tab stop; the stepper buttons are skipped, as the arrow keys do the same. Focus turns the field's edge `--ring`. A press on a button keeps the focus in the field.

**Known limits.**

- The screen reader reads the number as the field shows it, formatted; it has no `spinbutton` role or `aria-valuenow`.
- Typed text outside `min` and `max` is kept until the field loses focus, then brought into range.
- With a `step` of its own and `min`, a typed number that is not `min` plus whole steps fails the form's step check, as a native number input does; without `step`, any number submits.
- Base UI's scrub area (drag a label to change the value) is not part of this component; `allowWheelScrub` turns on the mouse wheel.
- The stacked buttons are 36 × 16.5 px, under WCAG 2.5.8's 24 px, as in the visual target; the field takes the same steps from the keyboard, and the horizontal and vertical buttons are full size.

### Keyboard

| Key | Behaviour |
| --- | --- |
| ↑ | Adds `step`; with Shift, `largeStep`; with Alt, `smallStep`. Stops at `max`. |
| ↓ | Takes away `step`; with Shift, `largeStep`; with Alt, `smallStep`. Stops at `min`. |
| Page Up | Adds `largeStep`. Stops at `max`. |
| Page Down | Takes away `largeStep`. Stops at `min`. |
| Home | Sets the value to `min`, when there is one. |
| End | Sets the value to `max`, when there is one. |
| 0–9 | Types digits; the locale's decimal and group separators, a minus sign and the currency or unit are also taken, other characters are refused. |

## API

### InputNumber

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `allowOutOfRange` | `boolean` | `false` | When true, direct text entry may be outside the `min`/`max` range without clamping, so native range underflow/overflow validation can occur. Step-based interactions (keyboard arrows, buttons, wheel, scrub) still clamp. |
| `allowWheelScrub` | `boolean` | `false` | Whether to allow the user to scrub the input value with the mouse wheel while focused and hovering over the input. |
| `aria-roledescription` | `string` | `'Number field'` | A user-friendly description of the input's role for assistive tech. This is a role description, not an accessible name — use `Field.Label` or `aria-label` to name the control. |
| `buttons` | `"horizontal" \| "vertical" \| "stacked"` |  | Shows stepper buttons: `stacked` at the end, `horizontal` on both sides, `vertical` above and below. |
| `className` | `string` |  | Classes for the outer box. |
| `defaultValue` | `number` |  | The uncontrolled value of the field when it's initially rendered. To render a controlled number field, use the `value` prop instead. |
| `disabled` | `boolean` | `false` | Whether the component should ignore user interaction. |
| `fluid` | `boolean` | `false` | Fills the width of its container. |
| `form` | `string` |  | Identifies the form that owns the hidden input. Useful when the number field is rendered outside the form. |
| `format` | `NumberFormatOptions` |  | Options to format the input value. |
| `id` | `string` |  | The id of the input element. |
| `inputRef` | `Ref<HTMLInputElement>` |  | A ref to access the hidden input element. |
| `largeStep` | `number` | `10` | The large step value of the input element when incrementing while the shift key is held. Snaps to multiples of this value when `snapOnStep` is enabled. |
| `locale` | `LocalesArgument` |  | The locale the number is shown and read in. Defaults to the provider's `locale`. |
| `max` | `number` |  | The maximum value of the input element. |
| `min` | `number` |  | The minimum value of the input element. |
| `name` | `string` |  | Identifies the field when a form is submitted. |
| `onValueChange` | `((value: number \| null, eventDetails: NumberFieldRootChangeEventDetails) => void)` |  | Callback fired when the number value changes. The `eventDetails.reason` indicates what triggered the change: - `'input-change'` for parseable typing or programmatic text updates - `'input-clear'` when the field becomes empty - `'input-blur'` when formatting (and clamping, if enabled) occurs on blur - `'input-paste'` for paste interactions - `'keyboard'` for arrow-key/Home/End stepping (typing digits uses `'input-change'`/`'input-clear'`) - `'increment-press'` / `'decrement-press'` for button presses on the increment and decrement controls - `'wheel'` for wheel-based scrubbing - `'scrub'` for scrub area drags |
| `onValueCommitted` | `((value: number \| null, eventDetails: NumberFieldRootCommitEventDetails) => void)` |  | Callback function that is fired when the value is committed. It runs later than `onValueChange`, when: - The input is blurred after typing a value. - The pointer is released after scrubbing or pressing the increment/decrement buttons. It runs simultaneously with `onValueChange` when interacting with the keyboard or the mouse wheel. **Warning**: This is a generic event not a change event. |
| `prefix` | `ReactNode` |  | Text drawn inside the field before the number, such as a unit; read with the field as its description. |
| `readOnly` | `boolean` | `false` | Whether the user should be unable to change the field value. |
| `required` | `boolean` | `false` | Whether the user must enter a value before submitting a form. |
| `size` | `"default" \| "sm" \| "lg"` |  | The field's size: 28, 35 or 42 px tall. Defaults to the provider's `controlSize`. |
| `smallStep` | `number` | `0.1` | The small step value of the input element when incrementing while the alt key is held. Snaps to multiples of this value when `snapOnStep` is enabled. |
| `snapOnStep` | `boolean` | `false` | Whether the value should snap to the nearest step when incrementing or decrementing. |
| `step` | `number \| "any"` | `1` | Amount to increment and decrement with the buttons and arrow keys, or to scrub with pointer movement in the scrub area. To always enable step validation on form submission, specify the `min` prop explicitly in conjunction with this prop. Specify `step="any"` to always disable step validation; interactive stepping then uses a base amount of `1`, while the alt and shift keys still step by `smallStep` and `largeStep`. |
| `style` | `CSSProperties \| ((state: NumberFieldInputState) => CSSProperties)` |  | Style applied to the element, or a function that returns a style object based on the component's state. |
| `suffix` | `ReactNode` |  | Text drawn inside the field after the number, such as a unit; read with the field as its description. |
| `value` | `number \| null` |  | The raw numeric value of the field. |
| `variant` | `"default" \| "filled"` |  | `filled` draws the grey `--field-filled` fill. Defaults to the provider's `fieldVariant`. |

Every part takes `className`, merged with its defaults by `cn()`, and `ref`, which reaches the element it renders.

**Data attributes:** `data-slot="input-number-increment"` (InputNumber), and `data-size`, `data-variant`, `data-buttons`.

**Provider strings:** `increment`, `decrement`, `numberFieldRole` (`BooleanUIProvider`'s `strings`).

## Theming

The field is `--field` (`--field-filled` when filled) with the `--control` edge, `--control-hover` under the pointer and `--ring` on focus; invalid draws `--invalid`; disabled fills `--field-disabled` with `--field-disabled-foreground` text. The stepper icons are `--control-hover`, on `--accent` under the pointer and `--secondary-hover` while pressed.

| Token | Used for |
| --- | --- |
| `--accent` | background |
| `--control` | border |
| `--control-hover` | text, border |
| `--destructive-strong` | text |
| `--field` | background |
| `--field-disabled` | background |
| `--field-disabled-foreground` | text |
| `--field-filled` | background |
| `--foreground` | text |
| `--invalid` | border |
| `--muted-foreground` | text |
| `--ring` | border |
| `--secondary-foreground` | text |
| `--secondary-hover` | background |
| `--secondary-hover-foreground` | text |
