The number input provides controls for editing, incrementing, or decrementing numeric values using the keyboard or pointer. It handles floating-point rounding, scrubbing interaction, and value formatting via Intl.NumberFormat.
Features
- Based on the spinbutton ARIA pattern
- Scroll wheel, pointer scrubbing, and keyboard increment/decrement
- Handles floating-point rounding errors with step snapping
- Press-and-hold spin buttons for continuous increment/decrement
- Rounds value to specific fraction digit counts via
formatOptions
- Currency, percent, and custom number formatting
Installation
npm install @zag-js/number-input @zag-js/react
Usage
import { useMachine, normalizeProps } from "@zag-js/react"
import * as numberInput from "@zag-js/number-input"
import { useId } from "react"
export function NumberInput() {
const service = useMachine(numberInput.machine, {
id: useId(),
defaultValue: "0",
min: 0,
max: 100,
})
const api = numberInput.connect(service, normalizeProps)
return (
<div {...api.getRootProps()}>
<label {...api.getLabelProps()}>Quantity</label>
<div>
<button {...api.getDecrementTriggerProps()}>-</button>
<input {...api.getInputProps()} />
<button {...api.getIncrementTriggerProps()}>+</button>
</div>
</div>
)
}
<script setup>
import { useMachine, normalizeProps } from "@zag-js/vue"
import * as numberInput from "@zag-js/number-input"
import { useId, computed } from "vue"
const service = useMachine(numberInput.machine, {
id: useId(),
defaultValue: "0",
min: 0,
max: 100,
})
const api = computed(() => numberInput.connect(service, normalizeProps))
</script>
<template>
<div v-bind="api.getRootProps()">
<label v-bind="api.getLabelProps()">Quantity</label>
<div>
<button v-bind="api.getDecrementTriggerProps()">-</button>
<input v-bind="api.getInputProps()" />
<button v-bind="api.getIncrementTriggerProps()">+</button>
</div>
</div>
</template>
import { useMachine, normalizeProps } from "@zag-js/solid"
import * as numberInput from "@zag-js/number-input"
import { createMemo, createUniqueId } from "solid-js"
export function NumberInput() {
const service = useMachine(numberInput.machine, {
id: createUniqueId(),
defaultValue: "0",
min: 0,
max: 100,
})
const api = createMemo(() => numberInput.connect(service, normalizeProps))
return (
<div {...api().getRootProps()}>
<label {...api().getLabelProps()}>Quantity</label>
<div>
<button {...api().getDecrementTriggerProps()}>-</button>
<input {...api().getInputProps()} />
<button {...api().getIncrementTriggerProps()}>+</button>
</div>
</div>
)
}
Setting the initial value
Pass defaultValue to set an uncontrolled initial value. The value must be a string.
const service = useMachine(numberInput.machine, {
id: useId(),
defaultValue: "13",
})
Controlled value
Use value and onValueChange to control the value externally.
const [value, setValue] = useState("0")
const service = useMachine(numberInput.machine, {
id: useId(),
value,
onValueChange(details) {
// details => { value: string, valueAsNumber: number }
setValue(details.value)
},
})
Min, max, and overflow
Set min and max to constrain the range. By default the value clamps to stay within bounds.
const service = useMachine(numberInput.machine, {
id: useId(),
min: 10,
max: 200,
})
To allow the value to exceed the range (and use onValueInvalid for feedback), pass allowOverflow: true.
const service = useMachine(numberInput.machine, {
id: useId(),
min: 0,
max: 10,
allowOverflow: true,
onValueInvalid(details) {
// details => { value, valueAsNumber, reason: "rangeUnderflow" | "rangeOverflow" }
console.log(details.reason)
},
})
Scroll wheel increment
Activate scroll-wheel increment/decrement by setting allowMouseWheel: true.
const service = useMachine(numberInput.machine, {
id: useId(),
allowMouseWheel: true,
})
Clamp on blur
By default, values are clamped to min/max when the input loses focus. Disable this with clampValueOnBlur: false.
const service = useMachine(numberInput.machine, {
id: useId(),
clampValueOnBlur: false,
})
Number formatting
Pass standard Intl.NumberFormatOptions to formatOptions to control display and precision.
// Currency
const service = useMachine(numberInput.machine, {
id: useId(),
formatOptions: {
style: "currency",
currency: "USD",
},
})
// Decimal precision
const service = useMachine(numberInput.machine, {
id: useId(),
formatOptions: {
maximumFractionDigits: 4,
minimumFractionDigits: 2,
},
})
Usage within forms
Set name to include the value in form submissions.
const service = useMachine(numberInput.machine, {
id: useId(),
name: "quantity",
})
Accessibility labels
Use translations to customize increment/decrement button labels and value text for screen readers.
const service = useMachine(numberInput.machine, {
id: useId(),
translations: {
incrementLabel: "Increase quantity",
decrementLabel: "Decrease quantity",
valueText: (value) => `${value} units`,
},
})
API reference
The initial value of the number input (uncontrolled).
The controlled value of the number input. Use with onValueChange.
The minimum allowed value.
The maximum allowed value.
The step amount for increment/decrement. Defaults to 1.
Whether to allow the value to exceed min/max. Defaults to false.
Whether to clamp the value to the range on blur. Defaults to true.
Whether to increment/decrement with the scroll wheel.
Whether to continuously spin value while the spin button is pressed. Defaults to true.
Options passed to Intl.NumberFormat for display formatting and parsing.
inputMode
"text" | "tel" | "numeric" | "decimal"
The virtual keyboard type to show on mobile.
The name attribute of the hidden input used in form submissions.
Whether the number input is disabled.
Whether the number input is read-only.
Whether the number input is in an invalid state.
onValueChange
(details: { value: string, valueAsNumber: number }) => void
Callback fired whenever the value changes.
onValueCommit
(details: { value: string, valueAsNumber: number }) => void
Callback fired when the value is committed (on blur or Enter key).
onValueInvalid
(details: { value: string, valueAsNumber: number, reason: string }) => void
Callback fired when the value falls outside the allowed range.
Localized labels for increment/decrement triggers and value text.
Styling
Each part of the number input has a data-part attribute for CSS targeting.
Part names
[data-part="root"] { /* wrapper element */ }
[data-part="label"] { /* the label */ }
[data-part="input"] { /* the text input */ }
[data-part="increment-trigger"] { /* the + button */ }
[data-part="decrement-trigger"] { /* the - button */ }
[data-part="scrubber"] { /* scrub interaction element */ }
Disabled state
[data-part="root"][data-disabled] { /* disabled root */ }
[data-part="label"][data-disabled] { /* disabled label */ }
[data-part="input"][data-disabled] { /* disabled input */ }
[data-part="increment-trigger"][data-disabled] { /* disabled + button */ }
[data-part="decrement-trigger"][data-disabled] { /* disabled - button */ }
Invalid state
[data-part="root"][data-invalid] { /* invalid root */ }
[data-part="label"][data-invalid] { /* invalid label */ }
[data-part="input"][data-invalid] { /* invalid input */ }
Read-only state
[data-part="input"][data-readonly] { /* read-only input */ }
Keyboard interactions
| Key | Description |
|---|
ArrowUp | Increments the value by the step amount |
ArrowDown | Decrements the value by the step amount |
PageUp | Increments the value by a larger step (10× step) |
PageDown | Decrements the value by a larger step (10× step) |
Home | Sets the value to the min |
End | Sets the value to the max |
Enter | Commits the current typed value |