Skip to the content

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.

import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberButtons() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-4">
      <div className="flex flex-col gap-2">
        <Label htmlFor="seat-price">Seat price</Label>
        <InputNumber
          id="seat-price"
          fluid
          buttons="stacked"
          defaultValue={20}
          format={{ style: "currency", currency: "USD" }}
        />
      </div>
      <div className="flex flex-col gap-2">
        <Label htmlFor="team-seats">Team seats, 0 to 100</Label>
        <InputNumber id="team-seats" fluid buttons="stacked" defaultValue={25} min={0} max={100} />
      </div>
    </div>
  )
}

Buttons on both sides

buttons="horizontal" puts minus before the field and plus after it, each in the field's edge.

import { InputNumber } from "@booleanpress/ui/input-number"
import { Label } from "@booleanpress/ui/label"

export default function InputNumberButtonsHorizontal() {
  return (
    <div className="flex w-full max-w-64 flex-col gap-2">
      <Label htmlFor="credit-top-up">Credit top-up</Label>
      <InputNumber
        id="credit-top-up"
        fluid
        buttons="horizontal"
        defaultValue={10.25}
        step={0.25}
        locale="de-DE"
        format={{ style: "currency", currency: "EUR" }}
      />
    </div>
  )
}

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.

import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberFilled() {
  return <InputNumber variant="filled" aria-label="Daily limit" placeholder="Daily limit" />
}

Disabled

Neither the text nor the buttons can be used, and the number is not submitted.

import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberDisabled() {
  return (
    <InputNumber disabled aria-label="Discount" defaultValue={50} format={{ style: "unit", unit: "percent" }} buttons="stacked" />
  )
}

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.

import { InputNumber } from "@booleanpress/ui/input-number"

export default function InputNumberReadOnly() {
  return (
    <InputNumber readOnly aria-label="Emails sent this month" defaultValue={8420} buttons="stacked" />
  )
}

Accessibility

Semantics
A native text input with inputmode="decimal" (or numeric), described as a number field through aria-roledescription (the provider string numberFieldRole). The stepper buttons are native buttons outside the tab order, named by the provider strings increment and decrement, and disabled at a bound. A hidden native number input carries the value for forms.
Labels
Name it with a Label whose htmlFor is the field's id, or aria-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 in aria-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 spinbutton role or aria-valuenow.
  • Typed text outside min and max is kept until the field loses focus, then brought into range.
  • With a step of its own and min, a typed number that is not min plus whole steps fails the form's step check, as a native number input does; without step, any number submits.
  • Base UI's scrub area (drag a label to change the value) is not part of this component; allowWheelScrub turns 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.

Keyboard

Keyboard
KeyBehaviour
↑Adds step; with Shift, largeStep; with Alt, smallStep. Stops at max.
↓Takes away step; with Shift, largeStep; with Alt, smallStep. Stops at min.
Page UpAdds largeStep. Stops at max.
Page DownTakes away largeStep. Stops at min.
HomeSets the value to min, when there is one.
EndSets the value to max, when there is one.
0–9Types 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

InputNumber props
PropTypeDefaultDescription
allowOutOfRangebooleanfalseWhen 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.
allowWheelScrubbooleanfalseWhether to allow the user to scrub the input value with the mouse wheel while focused and hovering over the input.
aria-roledescriptionstring'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.
classNamestringClasses for the outer box.
defaultValuenumberThe uncontrolled value of the field when it's initially rendered. To render a controlled number field, use the value prop instead.
disabledbooleanfalseWhether the component should ignore user interaction.
fluidbooleanfalseFills the width of its container.
formstringIdentifies the form that owns the hidden input. Useful when the number field is rendered outside the form.
formatNumberFormatOptionsOptions to format the input value.
idstringThe id of the input element.
inputRefRef<HTMLInputElement>A ref to access the hidden input element.
largeStepnumber10The 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.
localeLocalesArgumentThe locale the number is shown and read in. Defaults to the provider's locale.
maxnumberThe maximum value of the input element.
minnumberThe minimum value of the input element.
namestringIdentifies 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.
prefixReactNodeText drawn inside the field before the number, such as a unit; read with the field as its description.
readOnlybooleanfalseWhether the user should be unable to change the field value.
requiredbooleanfalseWhether 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.
smallStepnumber0.1The 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.
snapOnStepbooleanfalseWhether the value should snap to the nearest step when incrementing or decrementing.
stepnumber | "any"1Amount 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.
styleCSSProperties | ((state: NumberFieldInputState) => CSSProperties)Style applied to the element, or a function that returns a style object based on the component's state.
suffixReactNodeText drawn inside the field after the number, such as a unit; read with the field as its description.
valuenumber | nullThe 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.

Theme tokens
TokenUsed for
--accentbackground
--controlborder
--control-hovertext, border
--destructive-strongtext
--fieldbackground
--field-disabledbackground
--field-disabled-foregroundtext
--field-filledbackground
--foregroundtext
--invalidborder
--muted-foregroundtext
--ringborder
--secondary-foregroundtext
--secondary-hoverbackground
--secondary-hover-foregroundtext