Skip to content
cennge-ui
GitHubv0.3.1, changelog

NumberInput

A field for numbers: it refuses letters, steps with buttons and keys, keeps to its bounds and formats by locale.

1 to 16
In steps of 5
0 to 3
  1. Type a letter: it is refused. Type 50 into Parallel jobs and leave: it becomes 16.
  2. Arrows step once, PageUp and PageDown ten steps, Home and End jump to the bounds.
  3. Hold − or + to keep stepping; at a bound it stops and that button greys out.
Live: the component itself, not a picture of it.

Field marks

Import
import { NumberInput } from "cennge-ui";
Size
5.1 KB gzip for this import, its dependencies aside
Pattern
WAI-ARIA spinbutton on a text <input>
Keyboard
Arrow up Arrow down PageUpPageDownHomeEndEnter
State
Controlled with value and onValueChange, or uncontrolled; null is empty
SSR
Rendered on the server in the Next.js check; hydrates without a warning
Form value
The raw number, in a hidden input named name
Locale
en-US unless locale or CenngeProvider says otherwise
Tests
Unit tests; end-to-end in Chromium, Firefox and WebKit, on desktop and phone viewports: number-input

When to use#

Use a NumberInput when the value is a number someone may want to type exactly: a count, a price, a limit. The value you get is a number (or null when the field is empty), never the formatted text, and the text on screen follows the locale: $1,200, 1.234,50 €, 80%.

While the field has focus, what is typed stays as typed; when focus leaves, the number is clamped to min and max, rounded and formatted. onValueChange already fires during typing, for every text that reads as a number.

Similar#

  • Slider picks a value inside a range by dragging, when the exact number matters less than where it sits.
  • Input is for text that only looks like a number and is never counted: a postcode, a code, an id.
  • OtpInput is for one-time codes, a box per digit.

Currency, percent and units#

formatOptions are Intl.NumberFormat options. The sign, the unit and the grouping are added when focus leaves, and typing them back in is fine: $1,500 and 1500 read the same. Percent values are fractions, so 0.25 shows as 25% and steps by 0.01 unless you set step.

formats.tsx
import { NumberInput } from "cennge-ui";

export default function NumberFormats() {
  return (
    <div className="flex w-full max-w-2xl flex-wrap gap-5">
      <NumberInput
        label="Monthly budget"
        defaultValue={1200}
        min={0}
        step={100}
        formatOptions={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
        className="min-w-44 flex-1"
      />
      <NumberInput
        label="Cache size"
        defaultValue={512}
        min={64}
        max={4096}
        step={64}
        formatOptions={{ style: "unit", unit: "megabyte" }}
        className="min-w-44 flex-1"
      />
      <NumberInput
        label="Sample rate"
        defaultValue={0.25}
        min={0}
        max={1}
        step={0.05}
        formatOptions={{ style: "percent" }}
        className="min-w-44 flex-1"
      />
    </div>
  );
}

Another locale#

locale changes what is typed as well as what is shown: in de-DE the comma is the decimal separator and the dot groups thousands. Set locale on CenngeProvider once to change it for every field.

Type 2499,9 and leave the field

value: 1234.5

locale.tsx
import { useState } from "react";
import { NumberInput } from "cennge-ui";

export default function EuroPrice() {
  const [price, setPrice] = useState<number | null>(1234.5);

  return (
    <div className="flex w-full max-w-80 flex-col gap-2">
      <NumberInput
        label="Price"
        locale="de-DE"
        min={0}
        formatOptions={{ style: "currency", currency: "EUR" }}
        value={price}
        onValueChange={setPrice}
        description="Type 2499,9 and leave the field"
      />
      <p className="font-mono text-[13px] text-muted">value: {String(price)}</p>
    </div>
  );
}

Empty means null#

Clearing the field reports null, and a null value shows the placeholder. From empty, the first step up lands on min (or 0 without one).

Leave it empty for no limit

value: null

empty.tsx
import { useState } from "react";
import { NumberInput } from "cennge-ui";

export default function RateLimit() {
  const [limit, setLimit] = useState<number | null>(null);

  return (
    <div className="flex w-full max-w-80 flex-col gap-2">
      <NumberInput
        label="Requests per minute"
        placeholder="No limit"
        min={1}
        step={10}
        value={limit}
        onValueChange={setLimit}
        description="Leave it empty for no limit"
      />
      <p className="font-mono text-[13px] text-muted">value: {String(limit)}</p>
    </div>
  );
}

In a form#

With a name, a hidden input carries the raw number, so the form gets 20000, not $20,000. Enter clamps the typed value before the form submits: type 50000 here and press it.

Up to $20,000
in-a-form.tsx
import { useState } from "react";
import { Button, NumberInput } from "cennge-ui";

export default function BudgetForm() {
  const [submitted, setSubmitted] = useState("");

  return (
    <form
      className="flex w-full max-w-80 flex-col gap-4"
      onSubmit={(event) => {
        event.preventDefault();
        setSubmitted(JSON.stringify(Object.fromEntries(new FormData(event.currentTarget))));
      }}
    >
      <NumberInput
        label="Budget"
        name="budget"
        defaultValue={12000}
        min={0}
        max={20000}
        formatOptions={{ style: "currency", currency: "USD", maximumFractionDigits: 0 }}
        description="Up to $20,000"
      />
      <Button type="submit" className="self-start">
        Save
      </Button>
      {submitted ? <p className="font-mono text-[13px] text-muted">submitted: {submitted}</p> : null}
    </form>
  );
}

Accessibility#

The input has the spinbutton role with aria-valuenow, aria-valuemin, aria-valuemax and the formatted value as aria-valuetext. The − and + buttons are left out of the Tab order, since the arrow keys do the same; a pointer press on them keeps focus in the field. They are named "Decrease" and "Increase"; translate them with decrementLabel and incrementLabel.

On phones the keyboard is a decimal or numeric keypad. When negative numbers are allowed (no min, or a negative one), it falls back to the text keyboard, because iOS keypads have no minus key.

Styling#

It shares the Input's box: variant, color, size and radius, plus startContent and endContent. hideStepper removes the buttons and keeps the keys. classNames reaches base, label, inputWrapper, input, stepper, stepperButton, description and errorMessage.

Props#

PropTypeDefault
label
React.ReactNode
–
description
React.ReactNode
–
errorMessage
React.ReactNode
–
value
number | nullnull means empty.
–
defaultValue
number | null
–
onValueChange
(value: number | null) => voidFires while typing (for every parseable value) and on step, clamp or clear.
–
min
number
–
max
number
–
step
numberArrow keys and buttons.
1, or 0.01 for percent
largeStep
numberPageUp / PageDown.
step × 10
formatOptions
Intl.NumberFormatOptionsIntl.NumberFormat options: currency, percent, units, fraction digits, grouping.
–
locale
stringOverrides the CenngeProvider locale.
"en-US"
hideStepper
booleanHide the − / + buttons. Keyboard stepping still works.
–
isInvalid
boolean
–
isRequired
boolean
–
isDisabled
boolean
–
isReadOnly
boolean
–
variant
"bordered""flat""faded""underlined"
"flat"
color
"default""primary""secondary""success""warning""danger""info"Focus colour.
"primary"
size
"sm""md""lg"
"md"
radius
"none""sm""md""lg""full"
"md"
startContent
React.ReactNode
–
endContent
React.ReactNode
–
incrementLabel
string
"Increase"
decrementLabel
string
"Decrease"
classNames
Partial<Record<NumberInputSlots, string>>
–

Also takes every attribute of <input>, except size, color, value, defaultValue, onChange, min, max, step, type, inputMode, which mean something else here.