Number input
Enters a quantity, an amount or a percent in the locale's number format. Leave out the minus and plus buttons for a number nobody steps.
Installation
The CLI installs the Radix or Base UI file to match your project, with what it builds on: input-group.
If your project doesn't have Wisty UI's theme yet, add it once with npx shadcn@latest add @wisty/cosmos. Until then, components keep shadcn's default look. Installation has the details.
When to use
Use it
- People enter a whole or decimal number and often nudge it up or down, like a quantity, a probability or a number of seats.
- People type an amount or a percent, like an Opportunity's Amount or a Discount. Leave out the buttons, and put the
$or%in anInputGroupAddonbeside the field. The field shows125,000.00when it's left and125000while it's focused, and reads shortcuts like75k. - The number has bounds people shouldn't cross, like a quantity of at least 1 or a percent from 0 to 100. Pass
minandmax. - The field shows and reads the locale's format, like 1.234,5 in German, and a form still gets a plain number. Pass
locale. - It replaces a
lightning-inputwithtype="number", with or without aformatterofcurrencyorpercent.
Don't use it
- People pick a value from a range by feel, like a discount from 0 to 50%. Use Slider.
- The digits aren't a quantity, like a phone number, an account number or a postal code. Use Input.
- You're building a whole record form. Use the record form block, whose number fields read typed numbers with this
parseNumber. - The number is only read. Show it as text.
Usage
import {
NumberInput,
NumberInputDecrement,
NumberInputField,
NumberInputIncrement,
} from "@/components/wisty/number-input"NumberInput
├── NumberInputDecrement
├── NumberInputField
└── NumberInputIncrementNumberInput holds the value, the typed text and the bounds; the other parts only draw. Leave out NumberInputDecrement and NumberInputIncrement for an amount or a percent: the text then starts at the field's edge, and an InputGroupAddon from Input group holds the sign. The arrow keys still step the value. Put it in a Field and give NumberInputField the label's id. onValueChange gets the number, or null with the reason when the text isn't a number the field accepts. details.source says whether it was typed or stepped:
"use client"
import { useState } from "react"
import { type NumberInputError } from "@/components/wisty/number-input"export function Quantity() {
const [quantity, setQuantity] = useState<number | null>(1)
const [error, setError] = useState<NumberInputError>()
return (
<NumberInput
name="Quantity"
min={1}
value={quantity}
onValueChange={(value, details) => {
setQuantity(value)
setError(details.error)
}}
>
<NumberInputDecrement />
<NumberInputField aria-label="Quantity" />
<NumberInputIncrement />
</NumberInput>
)
}error is "format" for text that isn't a number, and "min" or "max" for a number outside the bounds. In a form, name submits the number as plain digits with a . decimal, like "1234.5", whatever the locale.
The field reads numbers in the locale's separators and digits: 1,234.5 in en-US, 1.234,5 in de-DE, −1 234,5 in sv-SE, ١٬٢٣٤٫٥ in ar-EG, with either minus sign. Text that isn't a number is a "format" error, like 12abc, or 1.5 in de-DE, where . groups thousands and a group of one digit is a typo. The field also reads lightning-input's shortcuts, k, m, b and t for thousands, millions, billions and trillions (1.5k is 1500), and exponents (2e2 is 200). parseNumber(text, locale) is exported for the same reading elsewhere: null for empty text, undefined for text that isn't a number. It reads shortcuts and exponents only with { shortcuts: true } as its third argument.
A percent is a fraction in a record, 0.6 for 60%, as Output field shows it, and the field shows the percentage. Convert at the field with toPercentage and toFraction, which keep 0.07 at 7, not 7.000000000000001, as the Percent example does.
Examples
Min and max
A probability from 0 to 100 in steps of 5, set with `min`, `max` and `step`, with the increment button disabled at 100.
Amount
An amount with no buttons and a $ addon, in two decimals with `fractionDigits`, grouped when the field is left and plain while it's focused.
Percent
A discount held as the fraction 0.15 and shown as 15 before a % addon, converted with `toPercentage` and `toFraction`.
Locale
A weight in German, set with `locale`: the field shows and reads 1.234,5, and a form gets 1234.5.
States
A required quantity showing the error `onValueChange` reports, a `disabled` discount, and a `readOnly` count with no buttons.
API reference
| Part | Built on |
|---|---|
NumberInput | InputGroup |
NumberInputField | <input> (opens in a new tab) |
NumberInputDecrement | InputGroupButton |
NumberInputIncrement | InputGroupButton |
NumberInput
| Prop | Type | Default | Description |
|---|---|---|---|
value | number | null | The number, or null when the field is empty or its text isn't one it accepts. | |
defaultValue | number | null | null | The number at first, when uncontrolled. |
onValueChange | ( value: number | null, details: NumberInputChangeDetails ) => void | Called with the new number, or null and the reason the text isn't one. | |
min | number | The smallest number people can step to or type. | |
max | number | The largest number people can step to or type. | |
step | number | 1 | A step's size. Steps land on multiples of it, counted from min or 0. |
locale | string | "en-US" | Formats and reads numbers. |
fractionDigits | number | Digits after the decimal point: typed numbers round to them, and the field shows them. | |
name | string | Submits the value as a plain number ("1234.5") with a form, from a hidden input. | |
required | boolean | false | Required with a form. |
disabled | boolean | false | Disables the field and its buttons. |
readOnly | boolean | false | Shows the number without the buttons; it can't be changed. |
parseNumberfunction
Reads a typed number in the locale's separators and digits: "1.234,5" in de-DE is 1234.5. Null for empty text, undefined for text that isn't a number, like "1.5" in de-DE, where a group of one digit is a typo.
| Parameter | Type | Default |
|---|---|---|
text* | string | |
locale | string | "en-US" |
__2 | { /** When true, also reads the shortcuts 1.5k, 2m, 3b and 4t, and exponents like 2e2. */ shortcuts?: boolean } | {} |
Returns number | null | undefined.
toPercentagefunction
A percent as the field shows it, from the fraction a record holds: 0.07 is 7, not the 7.000000000000001 that multiplying gives.
| Parameter | Type | Default |
|---|---|---|
fraction* | number |
Returns number.
toFractionfunction
A typed percentage as the fraction a record holds: 7 is 0.07.
| Parameter | Type | Default |
|---|---|---|
percentage* | number |
Returns number.
NumberInputErrortype
type NumberInputError = "format" | "min" | "max"NumberInputChangeDetailstype
| Member | Type | Default | Description |
|---|---|---|---|
source* | "type" | "step" | Typed and committed (blur or Enter), or stepped (buttons and keys). | |
text* | string | The field's text when the value changed. | |
error | NumberInputError | Why the text isn't a number the field accepts. Unset when it is. |
SLDS mapping
The number input is SLDS's counter. NumberInput is the form element's control, NumberInputField is slds-input slds-input_counter (centered text), and NumberInputDecrement and NumberInputIncrement are the small icon buttons inside it (slds-button_icon-small with slds-input__button_decrement and slds-input__button_increment). Without the buttons, NumberInputField is a plain slds-input with its text at the start, as lightning-input draws a number, and a $ or % addon is SLDS's fixed text (slds-input-has-fixed-addon). Read-only, it's a read-only slds-input with no buttons, as SLDS draws it.
Known gaps:
- The buttons' icons are lucide's circled minus and plus, where SLDS draws
utility:banandutility:new. - The
$or%sits in an addon inside the field's border, where SLDS draws fixed text outside it.lightning-input'sformatterdraws it in the text instead ($123.45), and picks the symbol and its side from the org's currency and locale. Here the page puts the symbol where the locale does:$before in en-US,€after in de-DE. - The buttons' names ("Decrement counter", "Increment counter") are English. Numbers follow
locale.
| Prop | Value | SLDS | Notes |
|---|---|---|---|
(no value) | default (added) | slds-input slds-input_counter, slds-button_icon-small slds-input__button_decrement, slds-input__button_increment | Centered text between a decrement button at the start and an increment button at the end. |
(no buttons) | (no NumberInputDecrement or NumberInputIncrement) (added) | slds-input, slds-input-has-fixed-addon | Text at the start with the input's own padding, as lightning-input type number draws it. A $ or % goes in an InputGroupAddon. |
min, max | (a number) (added) | slds-button_icon[disabled], slds-has-error | A button is disabled at its bound, and a typed number outside them is an error. |
locale | (a locale) (added) | lightning-input's user locale | The separators and digits the field shows and reads. Defaults to en-US. |
aria-invalid | true (added) | slds-has-error | Set by the number input for text it can't read or a number outside the bounds, or by the caller. |
disabled | true (added) | slds-input[disabled], slds-button_icon[disabled] | |
readOnly | true (added) | slds-input[readonly] | A read-only input with no buttons, as SLDS's read-only counter. |
Keyboard interactions
| Keys | Action |
|---|---|
| Up Arrow, Down Arrow | Step up or down by step, to the next multiple of it. |
| Shift+Up, Shift+Down | Step by ten steps. |
| Page Up, Page Down | Step by ten steps. |
| Home, End | Go to min or max, when set. |
| Enter | Reads the typed text and shows it in the locale's format, or marks it invalid. Leaving the field does the same. |
The buttons aren't in the tab order: the arrow keys do what they do.
Accessibility
- Name the field with a
FieldLabelwhosehtmlForisNumberInputField'sid, or passaria-labelto it. The buttons take their ownaria-label; give them the field's name when a form has several number inputs, like "Increase Quantity". - The field is a spin button: a screen reader hears its name, the number in the locale's format, and the bounds.
- A
$or%addon isn't part of the field's name or value. When the unit isn't clear from the label, say it there, like "Amount (EUR)". - For an error you decide, like an empty required field, set
aria-invalidonNumberInputFieldand pointaria-describedbyat the message. The number input setsaria-invaliditself, and the input's validity, for text it can't read and numbers outsideminandmax. - In Cosmos dark, text in an invalid field is about 4.3:1, below WCAG AA's 4.5:1. SLDS's own stylesheet does the same.
Rules
- Keep the value a number or
null. An empty field isnull, never 0. - Keep a percent a fraction in your state, and give
min,maxandstepas percentages. Put the%in an addon after the field, or "(%)" in the label of a counter. - A typed number outside
minandmaxisn't clamped: the value isnullwith a"min"or"max"error, and the field keeps the number so people can fix it. The buttons and keys never step past a bound. - A typed number off the step grid is kept as typed, like 7 with a
stepof 5. The next step lands on the grid, at 10 or 5. - With
fractionDigits, a typed number rounds to those digits, so the value is what the field shows. - Focused, the field shows the number without grouping,
125000for125,000.00, so people edit digits, not separators. Text with an error stays as typed. - Disabled, the number input submits nothing with a form, as a disabled input doesn't.
- Never pass
asChildorrender.