ComponentsForm
Input number
A number field: typing, the arrow keys and stepper buttons change a number shown in the reader's locale.
Import
import { InputNumber } from "@booleanpress/ui/input-number"Also install @base-ui/react: pnpm add @base-ui/react
Usage
InputNumber is Base UI's Number Field with the field look. It needs the @base-ui/react peer package, which only products that import @booleanpress/ui/input-number install. Name it with a Label whose htmlFor is its id.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export function SendLimit() {
return (
<>
<Label htmlFor="send-limit">Daily send limit</Label>
<InputNumber id="send-limit" defaultValue={500} min={0} buttons="stacked" />
</>
)
}It is uncontrolled with defaultValue, or controlled with value and onValueChange; the value is a number, or null when the field is empty. onValueCommitted is called once a change is final: on blur after typing, or when a stepper button is released. The number is shown in the provider's locale (1,234.5 in English, 1.234,5 in German); locale sets another for one field. format takes Intl.NumberFormat options: { style: "currency", currency: "EUR" }, { maximumFractionDigits: 0 }, { useGrouping: false }, or { style: "unit", unit: "kilogram" } for a unit read as part of the value. min and max bound the value: the arrows, the buttons, Home and End stop there, and typed text is brought back into range on blur. step (1) is what an arrow or a button adds, largeStep (10) what Page Up, Page Down or Shift with an arrow adds, smallStep (0.1) what Alt with an arrow adds.
buttons adds stepper buttons: stacked at the end, horizontal on both sides, vertical above and below. prefix and suffix draw text inside the field, such as a unit; they are read as the field's description. The field is as wide as its content; fluid fills the container. size is sm (28 px), default (35 px) or lg (42 px), and variant="filled" fills the field grey; both default to the provider's controlSize and fieldVariant. className goes on the outer box; the other attributes (placeholder, aria-*, onBlur) go on the input. With name, the number is submitted with its form, and a reset of the form puts an uncontrolled field back to its defaultValue.
Examples
Basic
A labelled field with a number in thousands, grouped in the provider's locale.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export default function InputNumberBasic() {
return (
<div className="flex flex-col gap-2">
<Label htmlFor="monthly-quota">Monthly email quota</Label>
<InputNumber id="monthly-quota" defaultValue={42723} />
</div>
)
}Decimals
format sets the digits: a whole number, a number without grouping, and one with two to five decimals.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export default function InputNumberDecimals() {
return (
<div className="flex w-full max-w-72 flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="batch-size">Batch size</Label>
<InputNumber id="batch-size" fluid defaultValue={42723} format={{ maximumFractionDigits: 0 }} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="ticket-number">Ticket number, without grouping</Label>
<InputNumber id="ticket-number" fluid defaultValue={58151} format={{ useGrouping: false }} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="response-hours">Average response, in hours</Label>
<InputNumber
id="response-hours"
fluid
defaultValue={2351.35}
format={{ minimumFractionDigits: 2, maximumFractionDigits: 5 }}
/>
</div>
</div>
)
}Locale
The same number in English, German and Indian grouping, each field inside its own BooleanUIProvider locale.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
import { BooleanUIProvider } from "@booleanpress/ui/provider"
const decimals = { minimumFractionDigits: 2 }
export default function InputNumberLocale() {
return (
<div className="flex w-full max-w-72 flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="revenue-us">United States</Label>
<BooleanUIProvider locale="en-US">
<InputNumber id="revenue-us" fluid defaultValue={115744} format={decimals} />
</BooleanUIProvider>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="revenue-de">Germany</Label>
<BooleanUIProvider locale="de-DE">
<InputNumber id="revenue-de" fluid defaultValue={635524} format={decimals} />
</BooleanUIProvider>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="revenue-in">India</Label>
<BooleanUIProvider locale="en-IN">
<InputNumber id="revenue-in" fluid defaultValue={732762} format={decimals} />
</BooleanUIProvider>
</div>
</div>
)
}Currency
format={{ style: "currency" }} with US dollars, euros, rupees and yen, each in its own locale.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
import { BooleanUIProvider } from "@booleanpress/ui/provider"
export default function InputNumberCurrency() {
return (
<div className="flex w-full max-w-72 flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="plan-usd">Plan price, US dollars</Label>
<BooleanUIProvider locale="en-US">
<InputNumber id="plan-usd" fluid defaultValue={1500} format={{ style: "currency", currency: "USD" }} />
</BooleanUIProvider>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="plan-eur">Plan price, euros</Label>
<BooleanUIProvider locale="de-DE">
<InputNumber id="plan-eur" fluid defaultValue={2500} format={{ style: "currency", currency: "EUR" }} />
</BooleanUIProvider>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="plan-inr">Plan price, rupees</Label>
<BooleanUIProvider locale="en-IN">
<InputNumber
id="plan-inr"
fluid
defaultValue={4250}
format={{ style: "currency", currency: "INR", currencyDisplay: "code" }}
/>
</BooleanUIProvider>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="plan-jpy">Plan price, yen</Label>
<BooleanUIProvider locale="ja-JP">
<InputNumber id="plan-jpy" fluid defaultValue={5002} format={{ style: "currency", currency: "JPY" }} />
</BooleanUIProvider>
</div>
</div>
)
}Prefix and suffix
A unit through format, a % and an "Expires in" prefix, and an "emails an hour" suffix at the field's end.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export default function InputNumberPrefixSuffix() {
return (
<div className="flex w-full max-w-72 flex-col gap-4">
<div className="flex flex-col gap-2">
<Label htmlFor="parcel-weight">Parcel weight</Label>
<InputNumber id="parcel-weight" fluid defaultValue={20} format={{ style: "unit", unit: "kilogram" }} />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="discount">Discount</Label>
<InputNumber id="discount" fluid defaultValue={50} min={0} max={100} prefix="%" />
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="key-expiry">API key expiry</Label>
<InputNumber
id="key-expiry"
fluid
defaultValue={10}
prefix="Expires in"
format={{ style: "unit", unit: "day", unitDisplay: "long" }}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="send-rate">Sending rate</Label>
<InputNumber id="send-rate" fluid defaultValue={120} suffix="emails an hour" />
</div>
</div>
)
}Buttons
buttons="stacked" puts an up and a down button at the end, inside the field's edge.
Buttons on both sides
buttons="horizontal" puts minus before the field and plus after it, each in the field's edge.
Vertical
buttons="vertical" puts the up button above a narrow field and the down button below.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export default function InputNumberVertical() {
return (
<div className="flex flex-col items-center gap-2">
<Label htmlFor="retry-attempts">Retries</Label>
<InputNumber id="retry-attempts" className="w-10" buttons="vertical" defaultValue={3} min={0} max={10} />
</div>
)
}Min and max
min and max stop the arrows and buttons; Home and End go to them, and the button at a bound turns off.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export default function InputNumberMinMax() {
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="bounce-threshold">Bounce threshold, 0 to 100</Label>
<InputNumber
id="bounce-threshold"
fluid
buttons="stacked"
defaultValue={95}
min={0}
max={100}
aria-describedby="bounce-threshold-hint"
/>
<p id="bounce-threshold-hint" className="text-xs text-muted-foreground">
Home goes to 0 and End to 100; at either end its button turns off.
</p>
</div>
)
}Step
step={15} for the arrows and buttons, largeStep={60} for Page Up and Page Down.
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export default function InputNumberStep() {
return (
<div className="flex w-full max-w-64 flex-col gap-2">
<Label htmlFor="retry-interval">Retry interval, in seconds</Label>
<InputNumber
id="retry-interval"
fluid
buttons="stacked"
defaultValue={30}
min={0}
step={15}
largeStep={60}
aria-describedby="retry-interval-hint"
/>
<p id="retry-interval-hint" className="text-xs text-muted-foreground">
The arrows step by 15; Page Up, Page Down and Shift with an arrow by 60.
</p>
</div>
)
}With float label
Inside a FloatLabel, over the field and on its edge.
import { FloatLabel } from "@booleanpress/ui/float-label"
import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"
export default function InputNumberFloatLabel() {
return (
<div className="flex flex-wrap items-end gap-4 pt-4.5">
<FloatLabel>
<InputNumber id="fl-over-limit" />
<Label htmlFor="fl-over-limit">Daily limit</Label>
</FloatLabel>
<FloatLabel variant="on">
<InputNumber id="fl-on-price" format={{ style: "currency", currency: "USD" }} />
<Label htmlFor="fl-on-price">Price</Label>
</FloatLabel>
</div>
)
}Sizes
sm is 28 px tall with 12 px text and icons, default 35 px with 14 px, lg 42 px with 16 px.
import { InputNumber } from "@booleanpress/ui/input-number"
export default function InputNumberSizes() {
return (
<div className="flex flex-col items-center gap-4">
<InputNumber size="sm" aria-label="Small" placeholder="Small" buttons="stacked" />
<InputNumber aria-label="Normal" placeholder="Normal" buttons="stacked" />
<InputNumber size="lg" aria-label="Large" placeholder="Large" buttons="stacked" />
</div>
)
}Filled
variant="filled" draws the grey --field-filled fill, which stays on hover and focus.
Disabled
Neither the text nor the buttons can be used, and the number is not submitted.
Invalid
aria-invalid draws the error edge and a red placeholder.
import { InputNumber } from "@booleanpress/ui/input-number"
export default function InputNumberInvalid() {
return (
<div className="flex flex-wrap justify-center gap-4">
<InputNumber aria-invalid aria-label="Amount" placeholder="Amount" />
<InputNumber aria-invalid aria-label="Amount, filled" placeholder="Amount" variant="filled" />
</div>
)
}Read-only
readOnly shows the number, which cannot change; the field can still be focused and copied.
Accessibility
- Semantics
- A native text
inputwithinputmode="decimal"(ornumeric), described as a number field througharia-roledescription(the provider stringnumberFieldRole). The stepper buttons are nativebuttons outside the tab order, named by the provider stringsincrementanddecrement, and disabled at a bound. A hidden native number input carries the value for forms. - Labels
- Name it with a
LabelwhosehtmlForis the field'sid, oraria-label. A prefix and a suffix are added to the field's description, so a unit such as "emails an hour" is read after the value. Put hints and errors inaria-describedby. - Focus
- The field is one tab stop; the stepper buttons are skipped, as the arrow keys do the same. Focus turns the field's edge
--ring. A press on a button keeps the focus in the field. - Known limits
- The screen reader reads the number as the field shows it, formatted; it has no
spinbuttonrole oraria-valuenow. - Typed text outside
minandmaxis kept until the field loses focus, then brought into range. - With a
stepof its own andmin, a typed number that is notminplus whole steps fails the form's step check, as a native number input does; withoutstep, any number submits. - Base UI's scrub area (drag a label to change the value) is not part of this component;
allowWheelScrubturns on the mouse wheel. - The stacked buttons are 36 × 16.5 px, under WCAG 2.5.8's 24 px, as in the visual target; the field takes the same steps from the keyboard, and the horizontal and vertical buttons are full size.
- The screen reader reads the number as the field shows it, formatted; it has no
Keyboard
| Key | Behaviour |
|---|---|
| ↑ | Adds step; with Shift, largeStep; with Alt, smallStep. Stops at max. |
| ↓ | Takes away step; with Shift, largeStep; with Alt, smallStep. Stops at min. |
| Page Up | Adds largeStep. Stops at max. |
| Page Down | Takes away largeStep. Stops at min. |
| Home | Sets the value to min, when there is one. |
| End | Sets the value to max, when there is one. |
| 0–9 | Types digits; the locale's decimal and group separators, a minus sign and the currency or unit are also taken, other characters are refused. |
API
InputNumber
| Prop | Type | Default | Description |
|---|---|---|---|
allowOutOfRange | boolean | false | When true, direct text entry may be outside the min/max range without clamping,
so native range underflow/overflow validation can occur.
Step-based interactions (keyboard arrows, buttons, wheel, scrub) still clamp. |
allowWheelScrub | boolean | false | Whether to allow the user to scrub the input value with the mouse wheel while focused and hovering over the input. |
aria-roledescription | string | 'Number field' | A user-friendly description of the input's role for assistive tech. This is a role
description, not an accessible name — use Field.Label or aria-label to name the control. |
buttons | "horizontal" | "vertical" | "stacked" | Shows stepper buttons: stacked at the end, horizontal on both sides, vertical above and below. | |
className | string | Classes for the outer box. | |
defaultValue | number | The uncontrolled value of the field when it's initially rendered.
To render a controlled number field, use the value prop instead. | |
disabled | boolean | false | Whether the component should ignore user interaction. |
fluid | boolean | false | Fills the width of its container. |
form | string | Identifies the form that owns the hidden input. Useful when the number field is rendered outside the form. | |
format | NumberFormatOptions | Options to format the input value. | |
id | string | The id of the input element. | |
inputRef | Ref<HTMLInputElement> | A ref to access the hidden input element. | |
largeStep | number | 10 | The large step value of the input element when incrementing while the shift key is held.
Snaps to multiples of this value when snapOnStep is enabled. |
locale | LocalesArgument | The locale the number is shown and read in. Defaults to the provider's locale. | |
max | number | The maximum value of the input element. | |
min | number | The minimum value of the input element. | |
name | string | Identifies the field when a form is submitted. | |
onValueChange | ((value: number | null, eventDetails: NumberFieldRootChangeEventDetails) => void) | Callback fired when the number value changes.
The eventDetails.reason indicates what triggered the change:
- 'input-change' for parseable typing or programmatic text updates
- 'input-clear' when the field becomes empty
- 'input-blur' when formatting (and clamping, if enabled) occurs on blur
- 'input-paste' for paste interactions
- 'keyboard' for arrow-key/Home/End stepping (typing digits uses 'input-change'/'input-clear')
- 'increment-press' / 'decrement-press' for button presses on the increment and decrement controls
- 'wheel' for wheel-based scrubbing
- 'scrub' for scrub area drags | |
onValueCommitted | ((value: number | null, eventDetails: NumberFieldRootCommitEventDetails) => void) | Callback function that is fired when the value is committed.
It runs later than onValueChange, when:
- The input is blurred after typing a value.
- The pointer is released after scrubbing or pressing the increment/decrement buttons.
It runs simultaneously with onValueChange when interacting with the keyboard or the
mouse wheel.
Warning: This is a generic event not a change event. | |
prefix | ReactNode | Text drawn inside the field before the number, such as a unit; read with the field as its description. | |
readOnly | boolean | false | Whether the user should be unable to change the field value. |
required | boolean | false | Whether the user must enter a value before submitting a form. |
size | "default" | "sm" | "lg" | The field's size: 28, 35 or 42 px tall. Defaults to the provider's controlSize. | |
smallStep | number | 0.1 | The small step value of the input element when incrementing while the alt key is held.
Snaps to multiples of this value when snapOnStep is enabled. |
snapOnStep | boolean | false | Whether the value should snap to the nearest step when incrementing or decrementing. |
step | number | "any" | 1 | Amount to increment and decrement with the buttons and arrow keys, or to scrub with pointer movement in the scrub area.
To always enable step validation on form submission, specify the min prop explicitly in conjunction with this prop.
Specify step="any" to always disable step validation; interactive stepping then uses a base amount of 1, while the alt and shift keys still step by smallStep and largeStep. |
style | CSSProperties | ((state: NumberFieldInputState) => CSSProperties) | Style applied to the element, or a function that returns a style object based on the component's state. | |
suffix | ReactNode | Text drawn inside the field after the number, such as a unit; read with the field as its description. | |
value | number | null | The raw numeric value of the field. | |
variant | "default" | "filled" | filled draws the grey --field-filled fill. Defaults to the provider's fieldVariant. |
Every part takes className, merged with its defaults by cn(), and ref, which reaches the element it renders.
Data attributes: data-slot="input-number-increment" (InputNumber), and data-size, data-variant, data-buttons.
Provider strings: increment, decrement, numberFieldRole (BooleanUIProvider's strings).
Theming
The field is --field (--field-filled when filled) with the --control edge, --control-hover under the pointer and --ring on focus; invalid draws --invalid; disabled fills --field-disabled with --field-disabled-foreground text. The stepper icons are --control-hover, on --accent under the pointer and --secondary-hover while pressed.
The tokens its classes read. Change their values in your theme, and it follows.
| Token | Used for |
|---|---|
--accent | background |
--control | border |
--control-hover | text, border |
--destructive-strong | text |
--field | background |
--field-disabled | background |
--field-disabled-foreground | text |
--field-filled | background |
--foreground | text |
--invalid | border |
--muted-foreground | text |
--ring | border |
--secondary-foreground | text |
--secondary-hover | background |
--secondary-hover-foreground | text |