---
id: "packages/active-ui/number-field"
title: "NumberField"
description: "A text field for amounts and quantities that reads and writes the reader's notation and hands your application exact decimal text."
url: "/packages/active-ui/number-field"
locale: "en"
revision: "892b3fdb087cd1c4"
---

# NumberField

Use `NumberField` wherever a person types a number that matters: an amount, a percentage, a
quantity. It shows and reads the notation of the presentation you give it: under a Dutch
presentation the reader types `1234,5`, under an English one `1234.5`. Your application receives
the same canonical decimal text either way: `'1234.5'`.

**Example: One value, three notations**

Client: Runs entirely in your browser. Source: `examples/ui/number-field-notation.tsx`.

```tsx
// apps/docs/examples/ui/number-field-notation.tsx
'use client';

import {useState} from 'react';

import type {NumberInputStatus} from '@workspace/active-format';
import {
  DescriptionItem,
  DescriptionList,
} from '@workspace/active-ui/components/composed/description-list';
import {NumberField} from '@workspace/active-ui/components/composed/number-field';
import {
  Field,
  FieldDescription,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';
import {
  ToggleGroup,
  ToggleGroupItem,
} from '@workspace/active-ui/components/primitives/toggle-group';

const LANGUAGES = {en: 'English', nl: 'Dutch', de: 'German'} as const;
type Language = keyof typeof LANGUAGES;

/**
 * One controlled field under three presentations. The field shows and reads
 * the notation of the presentation it receives; what travels to the
 * application is always canonical decimal text. Type `1234,5` under Dutch,
 * then switch to English: the value stays 1234.5 and the field rewrites it.
 */
export default function NumberFieldNotation() {
  const [language, setLanguage] = useState<Language>('nl');
  const [value, setValue] = useState('1234.5');
  const [status, setStatus] = useState<NumberInputStatus>('valid');
  return (
    <div className="flex max-w-sm flex-col gap-4">
      <ToggleGroup
        aria-label="Presentation"
        variant="outline"
        size="sm"
        spacing={0}
        value={[language]}
        onValueChange={(next: string[]) => {
          const chosen = next[0];
          if (chosen === 'en' || chosen === 'nl' || chosen === 'de') {
            setLanguage(chosen);
          }
        }}
      >
        {(Object.keys(LANGUAGES) as Language[]).map((code) => (
          <ToggleGroupItem key={code} value={code}>
            {LANGUAGES[code]}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <Field>
        <FieldLabel htmlFor="notation-amount">Annual salary</FieldLabel>
        <NumberField
          id="notation-amount"
          locale={language}
          value={value}
          onValueChange={setValue}
          onStateChange={(next) => setStatus(next)}
          decimals={2}
        />
        <FieldDescription>Up to two decimals.</FieldDescription>
      </Field>
      <DescriptionList orientation="inline" columns={1}>
        <DescriptionItem label="Value received">
          <span className="font-mono" data-number-value>
            {value === '' ? "'' (empty)" : `'${value}'`}
          </span>
        </DescriptionItem>
        <DescriptionItem label="Status">
          <span className="font-mono" data-number-status>
            {status}
          </span>
        </DescriptionItem>
      </DescriptionList>
    </div>
  );
}
```

## What the composition adds

`NumberField` is the [`Input`](/packages/active-ui/field) primitive plus the number reading of
[active-format](/packages/active-format/input). Compared with an `<input type="number">` or a
plain text input, it:

- reads the notation of the **presentation you pass**, not the browser's language, and writes the
  value back in that notation when the field loses focus;
- tells **typing from pasting**: under a Dutch presentation a pasted `1.234,56` is read as one amount, while a typed
  thousands separator is refused, so a half-typed `1.234` never silently becomes 1.234 or 1234;
- keeps **empty, incomplete and invalid apart**: `''` means cleared, and a half-typed or rejected
  text never changes the value your application holds;
- reports **why** a text is rejected, as a code your application turns into a message;
- applies a **domain contract**: the number of decimals, whether negative values are allowed, and
  the currency an amount may carry when it is pasted.

## Usage

```tsx
import {NumberField} from '@workspace/active-ui/components/composed/number-field';

const [value, setValue] = useState(''); // canonical decimal text, or ''

<NumberField
  id="amount"
  locale={presentation}
  value={value}
  onValueChange={setValue}
  decimals={2}
/>;
```

Place it in a [`Field`](/packages/active-ui/field) with a `FieldLabel`, like any other control.

## Validation and messages

**Example: A domain contract and its messages**

Client: Runs entirely in your browser. Source: `examples/ui/number-field-validation.tsx`.

```tsx
// apps/docs/examples/ui/number-field-validation.tsx
'use client';

import {useState} from 'react';

import type {
  NumberInputRejection,
  NumberInputStatus,
} from '@workspace/active-format';
import {NumberField} from '@workspace/active-ui/components/composed/number-field';
import {
  Field,
  FieldDescription,
  FieldError,
  FieldLabel,
} from '@workspace/active-ui/components/primitives/field';

/**
 * The application's own words for every reason the field can report. The
 * component does not translate: it reports a code, the application writes
 * the sentence in the reader's language.
 */
const MESSAGES: Record<NumberInputRejection, string> = {
  'not-a-number': 'Enter a number.',
  'cross-convention':
    'This looks like another notation, such as 1.234,56. Use a point for decimals.',
  'grouping-position': 'A thousands separator is in the wrong place.',
  'grouping-while-typing':
    'Type the number without thousands separators; pasting them is fine.',
  'unsupported-negative': 'Write a negative amount with a leading minus sign.',
  'unsupported-notation': 'Write the amount out in full.',
  'negative-not-allowed': 'The amount cannot be negative.',
  'precision-exceeded': 'Use at most two decimals.',
  'currency-mismatch': 'This amount is in another currency than euros.',
};

/**
 * A field with a domain contract: euros, two decimals, never negative. While
 * the reader is still typing (`incomplete`) no error shows; a rejection
 * shows the application's message for its reason. Try `-5`, `12.345`,
 * `1,234` and pasting `€ 1,234.50`.
 */
export default function NumberFieldValidation() {
  const [value, setValue] = useState('');
  const [state, setState] = useState<{
    status: NumberInputStatus;
    rejection?: NumberInputRejection;
  }>({status: 'empty'});
  const message =
    state.status === 'invalid' && state.rejection
      ? MESSAGES[state.rejection]
      : null;
  return (
    <div className="flex max-w-sm flex-col gap-3">
      <Field data-invalid={message ? true : undefined}>
        <FieldLabel htmlFor="validation-amount">Contribution (EUR)</FieldLabel>
        <NumberField
          id="validation-amount"
          locale="en"
          currency="EUR"
          decimals={2}
          allowNegative={false}
          value={value}
          onValueChange={setValue}
          onStateChange={(status, rejection) => setState({status, rejection})}
          aria-describedby={
            message
              ? 'validation-amount-description validation-amount-error'
              : 'validation-amount-description'
          }
        />
        <FieldDescription id="validation-amount-description">
          In euros, up to two decimals.
        </FieldDescription>
        {message ? (
          <FieldError id="validation-amount-error">{message}</FieldError>
        ) : null}
      </Field>
      <p className="text-muted-foreground text-sm">
        Value <span className="font-mono">{`'${value}'`}</span>, status{' '}
        <span className="font-mono" data-number-status>
          {state.status}
        </span>
        {state.rejection ? (
          <>
            , reason{' '}
            <span className="font-mono" data-number-rejection>
              {state.rejection}
            </span>
          </>
        ) : null}
      </p>
    </div>
  );
}
```

`onStateChange` reports, on every keystroke, one of four statuses:

| Status | Meaning | Show an error? |
| --- | --- | --- |
| `empty` | nothing typed | only if the field is required |
| `incomplete` | a valid beginning, such as `1,`; the reader is still typing | no |
| `invalid` | cannot become a number; a `rejection` says why | yes |
| `valid` | a value; `onValueChange` has received it | no |

The field sets `aria-invalid` itself when the status is `invalid`. The message is yours: map each
rejection code (`not-a-number`, `precision-exceeded`, `negative-not-allowed`, `grouping-while-typing`
and the others) to a sentence in the reader's language, and show it in a `FieldError`.

## What your application provides

- `locale`: the presentation, a language code such as `'nl'` or a presentation context from
  active-format. Which separator is a decimal one is a property of your data or your reader, not of
  this component.
- `value` and `onValueChange`: the value as canonical decimal text. If your domain stores numbers,
  convert at your own boundary with `numberInputValueFromNumber()` and `numberFromInputValue()`
  from active-format.
- The contract: `decimals`, `allowNegative`, `currency`, `allowPercentSign`.
- The label, the messages for each rejection, and when to show them.

## Accessibility and keyboard

- A text input with `inputMode="decimal"`: mobile keyboards show digits and a decimal separator.
- `aria-invalid` follows the status; connect the error with `aria-describedby`.
- The placeholder shows the expected notation; it is a hint, never a label.

## API

### NumberField

| Prop | Type | Required | Description |
| --- | --- | --- | --- |
| `locale` | `NumberPresentation` | yes | The presentation of this field: a bare language code (`'nl'`) or a generic presentation-context object that carries language and notation independently.  **Display, placeholder and parsing all come from this one source**, so by construction they cannot fall out of step. Which character is a decimal separator is the presentation of business data: in an application with a presentation setting (per administration, tenant or system) the value comes from there, not from the reader's language alone. A bare language code is right where no such setting exists.  Deliberately a prop: `active-ui` knows no administration, tenant or profile resolver. The application composes, this component receives. |
| `onValueChange` | `(value: string) => void` | yes | Receives the new canonical value, or `''`.  **`''` means only "cleared".** Incomplete (`1,`) and unacceptable (`1.234`, `abc`) text report **no** value: the last valid value stays, the text stays visible, and the state travels via {@link NumberFieldProps.onStateChange}. `DateField` follows the same rule, for the same reason: otherwise half a typo erases a stored value. |
| `value` | `string` | yes | The value as **canonical exact-decimal text** (`'1234.56'`), or `''` when there is no value.  **Never a `number`.** Two reasons, and the second weighs most. A `number` cannot carry the empty state: `0` and "not filled in" would become the same. And an exact decimal string the user has just typed should not needlessly pass through a binary floating-point conversion before the domain boundary.  A form whose domain value is and stays a `number` uses `numberInputValueFromNumber()` and `numberFromInputValue()` from `@workspace/active-format` at its own boundary. |
| `allowNegative` | `boolean` | no | Whether a negative value is semantically allowed. Defaults to `true`. |
| `allowPercentSign` | `boolean` | no | Whether a trailing percent sign may be stripped on paste.  Says nothing about the **scale**: whether this field carries percentage points or a fraction is a domain contract of the call site, and this component never guesses it. |
| `currency` | `string` | no | The field's currency identity, if it carries one.  Makes pasting an amount with **matching** decoration possible and gives conflicting decoration its own message. The field shows no symbol and never changes currency: identity keeps coming from the data. |
| `decimals` | `number` | no | The maximum number of decimals according to the **domain**: the schema precision, the minor units of the currency or the explicit field contract.  Never from a display preset and never from the notation. Drives both the message on too many decimals and the canonical redisplay on blur. |
| `onStateChange` | `((status: NumberInputStatus, rejection?: NumberInputRejection) => void)` | no | Receives on every keystroke the state and, on a rejection, the reason.  The distinction between `incomplete` and `invalid` exists so a field reports no error while the user is still typing. The `rejection` exists so a form can show a **specific** message instead of one generic "invalid value"; translating is the caller's job, because `active-ui` does not translate. |

**NumberFieldProps**

```ts
interface NumberFieldProps extends Omit<
  React.ComponentProps<'input'>,
  'value' | 'defaultValue' | 'onChange' | 'type' | 'step'
> {
  value: string;
  onValueChange: (value: string) => void;
  onStateChange?: (
    status: NumberInputStatus,
    rejection?: NumberInputRejection,
  ) => void;
  locale: NumberPresentation;
  decimals?: number;
  allowNegative?: boolean;
  currency?: string;
  allowPercentSign?: boolean;
}
```
