# NumberInput

> Package `@umriss-ui/core`, version 0.40.0. Demo page: <https://romanhaendler.github.io/umriss-ui/core/numberinput/>. Every page in one line: [llms.txt](https://romanhaendler.github.io/umriss-ui/core/llms.txt); every page in full: [llms-full.txt](https://romanhaendler.github.io/umriss-ui/core/llms-full.txt).

A number typed exactly (numeric field, spin box): an amount, a weight, hours. It shows the unit inside the field, keeps the value in its range and steps with the arrow keys.

```ts
import { NumberInput } from "@umriss-ui/core";
```

Demo page: https://romanhaendler.github.io/umriss-ui/core/numberinput/

The value is a number or `null`. An empty field is `null`, not zero: a figure nobody gave is not a figure of zero.

It writes and reads the number in the notation of the formats, so German users get a decimal comma through `@umriss-ui/core/wording/de` ([ADR-0024](https://github.com/romanhaendler/umriss-ui/blob/main/docs/adr/0024-the-formats-are-english-german-is-freight.md)). Every value reported is already clamped to the range; the text in the field follows when it is left.

## Examples

### Number field

The value is a number or `null`; the field writes it in the formats' notation and reads it back.

```tsx
import { useState } from "react";
import { FormField, NumberInput } from "@umriss-ui/core";

export default function NumberField() {
  const [weight, setWeight] = useState<number | null>(18.5);

  return (
    <FormField label="Parcel weight" style={{ maxWidth: 240 }}>
      <NumberInput value={weight} onChange={setWeight} min={0} decimals={1} suffix="kg" />
    </FormField>
  );
}
```

### Units and decimals

Put the unit in `prefix` or `suffix`, inside the field, and fix the places with `decimals`.

```tsx
import { useState } from "react";
import { FormField, Grid, NumberInput } from "@umriss-ui/core";

export default function UnitsAndDecimals() {
  const [budget, setBudget] = useState<number | null>(68000);
  const [discount, setDiscount] = useState<number | null>(2.5);
  const [variance, setVariance] = useState<number | null>(-4.25);

  return (
    <Grid minItemWidth="200px" gap={4}>
      <FormField label="Monthly budget" hint="Whole euros, step 500.">
        <NumberInput value={budget} onChange={setBudget} min={0} decimals={0} step={500} suffix="€" />
      </FormField>
      <FormField label="Early payment discount" hint="One decimal place.">
        <NumberInput value={discount} onChange={setDiscount} min={0} max={10} decimals={1} step={0.5} suffix="%" />
      </FormField>
      <FormField label="Forecast variance" hint="Negative allowed.">
        <NumberInput value={variance} onChange={setVariance} decimals={2} step={0.25} prefix="±" suffix="%" />
      </FormField>
    </Grid>
  );
}
```

### States

An empty field reports `null`, not zero; errors come from the `FormField`, and `disabled` holds the figure fixed.

```tsx
import { useState } from "react";
import { FormField, Grid, NumberInput, Text } from "@umriss-ui/core";

export default function States() {
  const [actual, setActual] = useState<number | null>(null);
  const [quantity, setQuantity] = useState<number | null>(0);

  return (
    <Grid minItemWidth="220px" gap={4}>
      <FormField label="Actual, March" hint="Not booked yet.">
        <NumberInput value={actual} onChange={setActual} decimals={0} suffix="€" />
        <Text size="xs" tone="muted" mono>
          value: {actual === null ? "null" : actual}
        </Text>
      </FormField>
      <FormField label="Quantity" error="An invoice line needs at least one unit.">
        <NumberInput value={quantity} onChange={setQuantity} min={0} decimals={0} />
      </FormField>
      <FormField label="VAT rate" hint="Set by the tax code.">
        <NumberInput value={19} onChange={() => {}} disabled suffix="%" />
      </FormField>
      <FormField label="Annual budget, all cost centres" hint="Small size, a long figure.">
        <NumberInput size="sm" value={9_048_000.5} onChange={() => {}} decimals={2} suffix="€" />
      </FormField>
    </Grid>
  );
}
```

### With an error

A `FormField` with an `error` marks the field invalid by itself. Without one, set `invalid` and tie your message to the field with `aria-describedby`.

```tsx
import { useId, useState } from "react";
import { NumberInput, Stack, Text } from "@umriss-ui/core";

export default function WithAnError() {
  const [quantity, setQuantity] = useState<number | null>(0);
  const messageId = useId();
  const error = quantity === null || quantity < 1 ? "An invoice line needs at least one unit." : undefined;

  return (
    <Stack gap={1} style={{ maxWidth: 280 }}>
      <NumberInput
        aria-label="Quantity"
        value={quantity}
        onChange={setQuantity}
        min={0}
        decimals={0}
        invalid={error !== undefined}
        aria-describedby={error ? messageId : undefined}
      />
      {error && (
        <Text id={messageId} size="xs" style={{ color: "var(--u-color-danger-text)" }}>
          {error}
        </Text>
      )}
    </Stack>
  );
}
```

### Keep a value in range

Set `min` and `max`: what `onChange` reports is always clamped, and the text follows when the field is left.

```tsx
import { useState } from "react";
import { FormField, Grid, NumberInput } from "@umriss-ui/core";

export default function KeepAValueInRange() {
  const [capacity, setCapacity] = useState<number | null>(32);
  const [focus, setFocus] = useState<number | null>(60);

  return (
    <Grid minItemWidth="220px" gap={4}>
      <FormField label="Capacity, Kofi Mensah" hint="0 to 48 hours a week; arrows count, Shift ×10.">
        <NumberInput value={capacity} onChange={setCapacity} min={0} max={48} decimals={0} suffix="h" />
      </FormField>
      <FormField label="Share for project work" hint="0 to 100 %, step 5.">
        <NumberInput value={focus} onChange={setFocus} min={0} max={100} step={5} decimals={0} suffix="%" />
      </FormField>
    </Grid>
  );
}
```

### Price an invoice line

Several number fields feed one derived figure; the total is computed from the values and never stored beside them.

```tsx
import { useState } from "react";
import { Card, CardBody, CardHeader, FormField, Grid, NumberInput, Stack, Text, useFormats } from "@umriss-ui/core";

export default function PriceAnInvoiceLine() {
  const formats = useFormats();
  const [quantity, setQuantity] = useState<number | null>(40);
  const [unitPrice, setUnitPrice] = useState<number | null>(12.9);
  const [discount, setDiscount] = useState<number | null>(5);

  const net = (quantity ?? 0) * (unitPrice ?? 0) * (1 - (discount ?? 0) / 100);

  return (
    <Card style={{ maxWidth: 560 }}>
      <CardHeader eyebrow="INV-2031, line 2" title="Printer paper, A4, 500 sheets" />
      <CardBody>
        <Stack gap={4}>
          <Grid minItemWidth="140px" gap={3}>
            <FormField label="Quantity">
              <NumberInput value={quantity} onChange={setQuantity} min={0} decimals={0} />
            </FormField>
            <FormField label="Unit price">
              <NumberInput value={unitPrice} onChange={setUnitPrice} min={0} decimals={2} step={0.1} suffix="€" />
            </FormField>
            <FormField label="Discount">
              <NumberInput value={discount} onChange={setDiscount} min={0} max={100} decimals={1} suffix="%" />
            </FormField>
          </Grid>
          <Text size="sm">
            Line total, net: <Text as="span" mono>{formats.number(net, 2)} €</Text>
          </Text>
        </Stack>
      </CardBody>
    </Card>
  );
}
```

## When to use something else

- A value set roughly, where the position between two bounds matters more than the exact figure → Slider
- A code or reference that is never calculated with → Input

## Keyboard

| Key | Action |
|---|---|
| `Arrow Up / Arrow Down` | Steps the value up or down by one step. |
| `Shift + Arrow Up / Down` | Steps by ten steps. |

## API

### `NumberInputProps`

| Prop | Type | Default | Description |
|---|---|---|---|
| `value` *required* | `number \| null` | — | The number, or `null` for "not stated". An empty field is not zero: a quantity that was not measured is not a quantity of zero.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [Number field](https://romanhaendler.github.io/umriss-ui/core/numberinput/#number-field), [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals) and 13 more |
| `min` | `number` | — | Lower bound. Every reported value is clamped; the text in the field follows on leaving.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [Number field](https://romanhaendler.github.io/umriss-ui/core/numberinput/#number-field), [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals) and 11 more |
| `max` | `number` | — | Upper bound. Every reported value is clamped; the text in the field follows on leaving.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals), [Keep a value in range](https://romanhaendler.github.io/umriss-ui/core/numberinput/#keep-a-value-in-range) and 5 more |
| `step` | `number` | `1` | Step size for the arrow keys; Shift multiplies it by ten.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals), [Keep a value in range](https://romanhaendler.github.io/umriss-ui/core/numberinput/#keep-a-value-in-range) and 3 more |
| `decimals` | `number` | — | Fixed decimal places; 0 = whole numbers, undefined = free.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [Number field](https://romanhaendler.github.io/umriss-ui/core/numberinput/#number-field), [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals) and 11 more |
| `prefix` | `ReactNode` | — | Adornment before the number, e.g. "±".<br>Shown in: [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals) |
| `suffix` | `ReactNode` | — | Adornment after the number, e.g. "€" or "%".<br>Shown in: [Number field](https://romanhaendler.github.io/umriss-ui/core/numberinput/#number-field), [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals), [States](https://romanhaendler.github.io/umriss-ui/core/numberinput/#states) and 9 more |
| `size` | `"sm" \| "md"` | the size of a `ControlSizeProvider` around it, else `"md"` | `sm` for a toolbar and dense forms, `md` otherwise.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [States](https://romanhaendler.github.io/umriss-ui/core/numberinput/#states), [Set up a team (Scenarios)](https://romanhaendler.github.io/umriss-ui/core/#set-up-a-team) |
| `chars` | `number` | — | The width in characters - room for the number; the field adds its own padding and steppers, and the room for a `prefix` and `suffix` given as text. Given, the field is that wide wherever it stands, and never wider than its place. Without it the field fills its place, and is 10 characters wide where the place asks - in a toolbar or a row.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [Sized to the value (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#sized-to-the-value) |
| `invalid` | `boolean` | — | Marks the field as invalid. `FormField` sets it itself as soon as it carries an `error` – by hand only needed without `FormField`.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [With an error](https://romanhaendler.github.io/umriss-ui/core/numberinput/#with-an-error) |
| `ref` | `Ref<HTMLInputElement>` | — | Receives the component's root element. From `RefProp`.<br>Shown in: [A row that holds still (Sizes)](https://romanhaendler.github.io/umriss-ui/core/sizes/#a-row-that-holds-still), [An anchored surface (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#anchored-surface), [Take the width of the trigger (Popover)](https://romanhaendler.github.io/umriss-ui/core/popover/#width-from-anchor) and 2 more |

#### Events

| Prop | Type | Default | Description |
|---|---|---|---|
| `onChange` *required* | `(value: number \| null) => void` | — | Runs on every valid input, with `null` for the emptied field – and always clamped: what goes out lies between `min` and `max`, by every route, while typing included. Only the text in the field may still stand outside while typing; on leaving it is set to the clamped value.<br>Shown in: [Configurator](https://romanhaendler.github.io/umriss-ui/core/numberinput/#configurator-numberinput), [Number field](https://romanhaendler.github.io/umriss-ui/core/numberinput/#number-field), [Units and decimals](https://romanhaendler.github.io/umriss-ui/core/numberinput/#units-and-decimals) and 13 more |

Also takes every attribute of `<input>` – without `onChange`, `prefix`, `size`, `type`, `value`.

## Known limits

- No expressions: “12*4” is not calculated.
- No currency conversion; the unit is a label.

What umriss deliberately does not build, and why: [ADR-0032](https://romanhaendler.github.io/umriss-ui/what-umriss-ui-is-not/).
