Input Number
A number field with buttons that step the value down and up. It has Input's height, border and states, comes in five layouts, keeps to a minimum and a maximum, and shows Western digits in every language.
Installation
npx @smicolon/fasla-ui@latest add input-numberRun init once per project before adding components. See Installation.
Preview
Variants
Five layouts, chosen with variant. default puts two Icon Buttons after the value. vertically-stacked stacks plus over minus in a narrow column, horizontally-stacked puts minus and plus side by side after the value, and horizontally-stretched puts minus before the value and plus after it. These three draw a 1px divider on the side of each button that faces the value. mini is 128px wide, for table cells and toolbars. Every layout is 36px tall, with the value centred.
States
The states are Input's. Focus is a real state, not a prop: tab to the field, or press a button. status="success" draws the border and the label in the success token, and invalid in the destructive token; the focus halo takes the same token. disabled turns off the field and both buttons, and dims the whole field to half opacity.
Limits and steps
min and max keep the value in range. At a limit, the matching button is disabled and stays in place, so the layout never shifts. step sets how far each press and each arrow key moves the value; Shift with an arrow moves it by 10.
Digits and formatting
The value is always drawn in Western digits, 0 to 9, in English and in Arabic, and a value typed in Arabic-Indic digits is read as the same number. format takes the options of Intl.NumberFormat, for a currency or a percentage, and locale sets the separators. The value keeps its own direction, so a minus sign stays before the digits on a right-to-left page.
Label
label puts a label above the field and links it to the input. Where the context already names the field, as in a table row, leave the label out and pass aria-label instead.
Accessibility
- The value is a text input with a numeric keyboard on phones. A hidden input submits the number with its form, under
name. - ArrowUp and ArrowDown step the value, Shift with an arrow moves it by 10, and Home and End jump to
minandmaxwhen they are set. - The buttons are named Increase and Decrease, or زيادة and إنقاص under an Arabic
lang. Rename them withincrementLabelanddecrementLabel. They stay out of the tab order, since the arrow keys do the same from the input, and pressing and holding one keeps stepping. - Don’t use
vertically-stackedon touch screens: its buttons are 23px wide and 17px tall, smaller than the 24px minimum touch target. Usehorizontally-stackedorhorizontally-stretchedinstead. labelnames the input. Without a visible label, passaria-labeloraria-labelledby.- Direction is inherited from
dir, never a prop. The layout uses logical properties only, so in Arabic the buttons and their dividers move to the other side and minus and plus trade places. The stacked layout keeps plus on top.
Props
InputNumber
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "default" | "vertically-stacked" | "horizontally-stacked" | "horizontally-stretched" | "mini" | "default" | Where the buttons sit: five layouts. |
value | number | null | — | The value, when you control it. Use it with onValueChange. |
defaultValue | number | — | The value on first render. |
onValueChange | (value, details) => void | — | Called with the new number, or null when the field is cleared. |
min | number | — | The lowest value. The decrement button is disabled there. |
max | number | — | The highest value. The increment button is disabled there. |
step | number | "any" | 1 | How far each press and each arrow key moves the value. |
format | Intl.NumberFormatOptions | — | Options for Intl.NumberFormat, such as a currency or a percentage. Digits stay Western. |
locale | Intl.LocalesArgument | — | The locale for separators and symbols. Default: the reader's. |
placeholder | string | — | Text shown while the field is empty. |
label | ReactNode | — | The field's label, linked to the input for you. |
aria-label | string | — | The input's accessible name when there is no visible label. |
aria-labelledby | string | — | The id of an element that names the input, in place of label. |
incrementLabel | string | — | The increment button's name. Default: Increase, or زيادة in Arabic. |
decrementLabel | string | — | The decrement button's name. Default: Decrease, or إنقاص in Arabic. |
status | "default" | "success" | "default" | Pass success to mark a value that passed validation. |
invalid | boolean | false | Marks the field invalid; aria-invalid does the same. |
disabled | boolean | false | Disables the field and both buttons, and dims the whole group. |
readOnly | boolean | false | Shows the value but stops it changing. |
required | boolean | false | The form submits only once the field has a value. |
name | string | — | The name the value is submitted under. |
form | string | — | The id of the form the value is submitted with, when the field sits outside it. |
id | string | — | The input's id, for a label element of your own. |
className | string | — | Extra CSS classes for the field group. |
Usage
Basic number field
import { InputNumber } from "@/components/ui/input-number"
export function QuantityField() {
return <InputNumber name="quantity" label="Quantity" defaultValue={1} min={1} max={10} />
}Controlled, with limits
"use client"
import * as React from "react"
import { InputNumber } from "@/components/ui/input-number"
export function GuestsField() {
const [guests, setGuests] = React.useState<number | null>(2)
return (
<InputNumber
variant="horizontally-stretched"
label="Guests"
value={guests}
onValueChange={setGuests}
min={1}
max={8}
/>
)
}Currency format
import { InputNumber } from "@/components/ui/input-number"
export function PriceField() {
return (
<InputNumber
name="price"
label="Price"
format={{ style: "currency", currency: "SAR" }}
defaultValue={1250}
min={0}
step={50}
/>
)
}