Base components
Number input
A number field with minus and plus buttons or an up and down stepper, where the buttons give a small press and the value changes at once so holding a button never waits for motion.
Live preview
Loading preview
Two fields at size md: "Seats" (horizontal, min 1, max 50, hint "Enter a whole number.") and "Budget" (vertical, US dollar format, step 100). Replay presses plus on Seats three times.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add number-inputUsage
The demo above, as code.
'use client';
import * as React from 'react';
import { NumberInput } from './number-input';
const USD: Intl.NumberFormatOptions = { style: 'currency', currency: 'USD', maximumFractionDigits: 0 };
export default function NumberInputDemo({ replay = 0 }: { replay?: number }) {
const [seats, setSeats] = React.useState<number | null>(2);
React.useEffect(() => {
if (replay === 0) return;
setSeats(2);
const timers = [1, 2, 3].map((press) => window.setTimeout(() => setSeats(2 + press), 300 * press));
return () => {
for (const timer of timers) window.clearTimeout(timer);
};
}, [replay]);
return (
<div className="flex w-full max-w-xs flex-col gap-6">
<NumberInput
label="Seats"
min={1}
max={50}
value={seats}
onValueChange={setSeats}
hint="Enter a whole number."
/>
<NumberInput
label="Budget"
layout="vertical"
format={USD}
locales="en-US"
step={100}
defaultValue={1200}
/>
</div>
);
}
API
Parts: field (label row, help icon, hint from input), group (border box), decrement button (minus, or the lower stepper half), text input (the number), increment button (plus, or the upper stepper half).
| Prop | Type | Default | Description |
|---|---|---|---|
| value | number | null | undefined | Value (controlled). null is an empty field. |
| defaultValue | number | null | null | Value when uncontrolled. |
| layout | "horizontal" | "vertical" | "horizontal" | horizontal: minus button, number, plus button, with the number centered. vertical: number on the left and a two-part stepper (up, down) at the right edge. |
| min | number | undefined | Smallest value. The decrement button turns off at min. |
| max | number | undefined | Largest value. The increment button turns off at max. |
| step | number | 1 | Change per button press or arrow key. |
| largeStep | number | 10 | Change per Page Up, Page Down, or Shift with an arrow key. |
| format | Intl.NumberFormatOptions | undefined | Number format, for example a currency or a percent; the value stays a plain number. |
| locales | string | "en-US" | Locale for the format and for parsing typed text. |
| size | "sm" | "md" | "lg" | "md" | Field height 40, 44 or 48px, matching Input. Horizontal buttons are square at the field height. |
| label | string | undefined | Visible label above the field. |
| placeholder | string | undefined | Text in the empty field. |
| hint | string | undefined | Hint line under the field; shows the error text when invalid. |
| helpText | string | undefined | Text for the help icon after the label (from input). |
| required | boolean | false | Shows the required mark and sets aria-required. |
| invalid | boolean | false | Error border and red hint, as in Input (the Figma counter variants have no error icon). |
| disabled | boolean | false | Turns the field and its buttons off (50% opacity). |
| readOnly | boolean | false | The value can be read and copied but not changed; the buttons are hidden. |
| name | string | undefined | Form field name; the plain number is sent. |
| Event | Description |
|---|---|
| valueChange | The value changed (a number or null), on each step and while typing a valid number. |
| valueCommit | The value is final: on blur, Enter, or when a held button is released. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| NIN1 | hover the field | group border | border-color | border-control → border-control_hover | duration-sm (200)ease-out | Swaps at once |
| NIN2 | keyboard focus in the text input | group | border-color, box-shadow | border-control, shadow-xs → border-brand, focus-ring-shadow-xs | duration-2xs (120)ease-out | Shows at once |
| NIN3 | hover a step button | step button | background-color, icon color | transparent, fg-quaternary → bg-primary_hover, fg-tertiary_hover | duration-sm (200)ease-out | Swaps at once |
| NIN4 | press a step button (pointer down) | step button icon | scale | 1 → scale-press | duration-sm (200)ease-out | Stays still |
| NIN5 | release a step button | step button icon | scale | scale-press → 1 | duration-sm (200)ease-out | Stays still |
| NIN6 | the value reaches min or max | decrement or increment button | opacity | 1 → 0.5 | duration-sm (200)ease-out | Swaps at once |
| NIN7 | invalid -> true | group border, hint | border-color, color | border-control, text-tertiary → border-error, text-error-primary | duration-sm (200)ease-out | Swaps at once |
Accessibility
- Built on a number field primitive: the text input has role textbox with inputmode decimal (or numeric for whole numbers) and aria-roledescription "Number field"; it is not type number, so formats and locales work.
- Keyboard: Up and Down step by step, Page Up and Page Down (or Shift with an arrow) by largeStep, Home and End go to min and max when they are set.
- The step buttons are named "Increase" and "Decrease" and are not tab stops (tabindex -1), because the arrow keys in the input do the same; the input is the one tab stop.
- Holding a step button repeats the step (the primitive's press-and-hold); the value is announced when the user stops, not on every repeat.
- Step buttons are at least 28 x 22px in the vertical stepper; a transparent hit area grows each to 24px high (WCAG 2.5.8).
- The label, hint and invalid state are linked as in Input.
- Reduced motion: colors swap at once and the step buttons do not scale. The value never animates, with or without reduced motion.