NumberInput
A field for numbers: it refuses letters, steps with buttons and keys, keeps to its bounds and formats by locale.
- Type a letter: it is refused. Type 50 into Parallel jobs and leave: it becomes 16.
- Arrows step once, PageUp and PageDown ten steps, Home and End jump to the bounds.
- Hold − or + to keep stepping; at a bound it stops and that button greys out.
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
valueandonValueChange, or uncontrolled;nullis 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-USunlesslocaleor 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.
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.
value: 1234.5
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).
value: null
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.
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#
Also takes every attribute of <input>, except size, color, value, defaultValue, onChange, min, max, step, type, inputMode, which mean something else here.