NumberInput

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.

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

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

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.

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.

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.

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.

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.

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

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

NameTypeDefaultDescription
valuerequirednumber | 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.

Shown in: Configurator, Number field, Units and decimals and 13 more

minnumber—Lower bound. Every reported value is clamped; the text in the field follows on leaving.

Shown in: Configurator, Number field, Units and decimals and 11 more

maxnumber—Upper bound. Every reported value is clamped; the text in the field follows on leaving.

Shown in: Configurator, Units and decimals, Keep a value in range and 5 more

stepnumber1Step size for the arrow keys; Shift multiplies it by ten.

Shown in: Configurator, Units and decimals, Keep a value in range and 3 more

decimalsnumber—Fixed decimal places; 0 = whole numbers, undefined = free.

Shown in: Configurator, Number field, Units and decimals and 11 more

prefixReactNode—Adornment before the number, e.g. "±".

Shown in: Units and decimals

suffixReactNode—Adornment after the number, e.g. "€" or "%".

Shown in: Number field, Units and decimals, 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.

Shown in: Configurator, States, Set up a team (Scenarios)

charsnumber—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.

Shown in: Configurator, Sized to the value (Sizes)

invalidboolean—Marks the field as invalid. FormField sets it itself as soon as it carries an error – by hand only needed without FormField.

Shown in: Configurator, With an error

refRef<HTMLInputElement>—Receives the component's root element. from RefProp

Shown in: A row that holds still (Sizes), An anchored surface (Popover), Take the width of the trigger (Popover) and 2 more

Events

NameTypeDefaultDescription
onChangerequired(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.

Shown in: Configurator, Number field, Units and decimals and 13 more

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

Known limits

What umriss deliberately does not build, and why: ADR-0032.

Every page of @umriss-ui/core

Getting started

Customising

Layout

Typography

Actions

Forms

Choosing

Dates and times

Feedback

Overlays

Navigation

Data display

API index