Number Input
FreeNumeric input with increment/decrement controls, prefix/suffix, min/max clamping and decimal precision.
import { SvNumberInputComponent } from '@styloviz/forms';Demo & usage
Basic usage
Usage example — open the gallery for the full interactive demo.
An interactive demo for this component lives in the gallery.
Open live demoMore examples
Currency with precision
<sv-number-input label="Price" variant="currency" [decimals]="2" [step]="0.5" [min]="0" [(value)]="price" />
Required + reactive forms
Implements ControlValueAccessor; required sets aria-required and errorMessage sets aria-invalid (linked via aria-describedby).
<sv-number-input formControlName="quantity" label="Quantity" [min]="1" [max]="99" [required]="true" [errorMessage]="form.controls.quantity.invalid ? 'Enter 1–99' : ''" />
Quantity stepper (PDP / cart)
The quantity preset is a compact stepper with large +/– buttons for product pages and cart rows.
<sv-number-input preset="quantity" [min]="1" [max]="10" ariaLabel="Quantity" [(value)]="qty" />
A reason rendered by the parent
When a composed control prints the explanation itself, point at it instead of passing it in as errorMessage — otherwise the message renders twice, or the field is a number with no stated reason it will not go higher.
<p id="stock-note">Only 3 left — your cart has 5</p> <sv-number-input ariaLabel="Quantity, Aurora Runner" ariaDescribedby="stock-note" [invalid]="true" [max]="3" [(value)]="qty" />
When to use
- Entering a precise number with optional min/max bounds and stepping.
- Quantities, prices, durations — anything where the exact value matters.
- For approximate values across a range, a Slider is friendlier.
Async states
None — this component has nothing to load, so it has no loading, empty or error state to drive.
Inputs
| Name | Type | Default | Description |
|---|---|---|---|
| value | number (two-way) | 0 | Current numeric value. Use `[(value)]` for two-way binding. |
| min | number | Number.NEGATIVE_INFINITY | Minimum allowed value. |
| max | number | Number.POSITIVE_INFINITY | Maximum allowed value. |
| step | number | 1 | Step amount for increment/decrement. |
| decimals | number | 0 | Number of decimal places. |
| clampOn | NumberInputClamp | 'blur' | When the typed value is forced into `[min, max]` and rounded to `decimals`. The defect this exists for: CLAMPING EVERY KEYSTROKE FIGHTS THE PERSON TYPING. This component used to clamp and round inside `input`, and `[value]` is bound to the formatted result — so the field rewrote itself between characters. With `min = 10`, selecting the contents and typing `50` went: `5` → clamped to `10` → the box now reads "10" → the `0` lands after it → `100`. The user typed two characters and got three, and nothing about the control said why. With `decimals = 0`, `1.5` was unreachable for the same reason: the `.` rounded away before the `5` arrived. THE ROUNDING IS THE SAME BUG AS THE CLAMPING, so both defer. An item about "clamp timing" that left `toFixed` on every keystroke would fix the half you can see. `'blur'` (the default) lets the field hold what was typed while it is being typed, and settles it when focus leaves. That value is genuinely out of range in between — see `outOfRange`, which is why the control says so rather than pretending. `'input'` restores the old behaviour for hosts that depend on the value never leaving range, even transiently. |
| label | string | '' | Label text above the input. |
| hint | string | '' | Hint text below the input (hidden when `errorMessage` is set). |
| errorMessage | string | '' | Error message shown below the input; sets `aria-invalid` and red styling. |
| serverError | string | { readonly message: string } | null | null | A validation failure reported by the **server** — the 422 that comes back after submit, not something the client could have known. It is a separate input from `errorMessage` because it has a different lifetime, not because it needs a second way to show a string. A server error is a statement about *the value that was submitted*: the moment the user edits the field it stops being true, and this input expires itself rather than sitting there telling someone their new email is already taken. `errorMessage` has no such expiry — it is the host's to clear. While live it outranks `errorMessage`: it is the more specific fact, and it is the one the user has not seen yet. Typed structurally rather than as `SvDataError` so this stays dependency free — an `SvDataError` satisfies it unchanged. |
| placeholder | string | '0' | Placeholder text. |
| prefix | string | '' | Text/symbol shown to the left of the value (e.g. '$'). |
| suffix | string | '' | Text/symbol shown to the right of the value (e.g. '%'). |
| inputId | string | '' | Explicit id for the input. Auto-generated when omitted. |
| ariaLabel | string | '' | `aria-label` for the input when there is no visible `label`. |
| ariaDescribedby | string | '' | Ids of description text rendered **outside** this component, appended to whatever `hint`/`errorMessage` already contribute. A composed control — a cart row that prints "Only 3 left" beside the title rather than under the stepper — otherwise has no way to associate the two. Passing the message in as `errorMessage` would render it twice; leaving it unassociated means a screen-reader user hears the quantity and none of the reason it is capped. Same rationale as `ariaLabel`: the visible thing lives in the parent, so the parent has to be able to point at it. |
| invalid | boolean | false | Mark the value invalid when the message explaining why is rendered by the parent (paired with `ariaDescribedby`). `errorMessage`/`serverError` already imply this; use it when the text is not ours to show. |
| name | string | '' | `name` for native form submission. |
| size | NumberInputSize | 'md' | Size variant. |
| variant | NumberInputVariant | 'default' | Format preset. Provides default `prefix`/`suffix` when you don't set them: `percentage` → `'%'` suffix; `currency` → see `currency` below. |
| currency | string | '' | ISO 4217 code — `'USD'`, `'EUR'`, `'JPY'`. |
| locale | string | '' | BCP-47 tag for the currency symbol's placement and the announced value. |
| preset | NumberInputPreset | 'default' | Layout preset. `quantity` renders a compact stepper with large +/– buttons, sized for comfortable tapping on product pages and cart rows. |
| disabled | boolean | false | Disable the input and buttons. |
| readonly | boolean | false | Make the input read-only (buttons still disabled). |
| required | boolean | false | Mark the field as required (native `required` + `aria-required`). |
| autofocus | boolean | false | Autofocus the input on render. |
Outputs
| Event | Payload type | Description |
|---|---|---|
| changed | number | Emits the new value after each change. |
| valueChange | number | Emits the new `value` whenever it changes — the output half of `[(value)]`. |
Customization & theming
- Add `prefix`/`suffix` adornments, or use a `variant` preset (currency → $, percentage → %); set `size`.
- Clamp with `min` / `max`, control decimal places with `decimals`, and the increment with `step`.
- Add `label`, `hint`, `errorMessage`, `required`, `placeholder`, `name`; two-way bind `[(value)]` or use a reactive control; listen to `(changed)`.
- Use `preset="quantity"` for a compact stepper with large +/– buttons, ideal for product pages and cart rows.
Best practices
- Do: Set `min`/`max` so out-of-range values are clamped on input and via the +/– buttons.
- Do: Use `prefix`/`suffix` (or the `variant` preset) for units (e.g. $, %) rather than the label alone.
- Do: Set `currency` (an ISO 4217 code) rather than relying on `variant="currency"`, which renders `$` for compatibility. With `locale` set, the symbol AND the side it goes on come from the platform's own CLDR data — `de-DE` writes `1.234,50 €` and `de-CH` writes `CHF 1'234.50`, so a `prefix` input can only ever produce the American shape.
- Tip: An empty `locale` means "do not localise", not "use the browser's". Defaulting to the browser would change what every existing caller renders the day it runs on a German laptop, so opting in is the only version of this that is not a silent behaviour change.
- Don't: Don't expect `locale` to change the input box. This is an `<input type="number">` and its value is canonical by specification — `.` for the decimal, no grouping — so `1.234,50` cannot go in the field. The browser already owns that job and does it: a German user on a German browser types `12,5` and the element reports `12.5`. What `locale` changes is the string screen readers announce, which the component builds itself.
- Tip: Set `ariaLabel` when there is no visible `label` (e.g. a compact inline stepper). The +/– buttons take their names from it too, so a list of steppers is not five pairs of "Increase value".
- Do: When the message explaining the limit is rendered by the parent, pass its id as `ariaDescribedby` and set `[invalid]` — don't re-send the text as `errorMessage`, which would render it twice.
Accessibility
- Built on the native `<input type="number">` — implicit spinbutton role with full keyboard support (Arrow Up/Down and direct typing); the +/– buttons have descriptive `aria-label`s.
- A unique id is generated automatically so the `<label for>` association works without `inputId`; use `ariaLabel` when there is no visible label.
- `prefix`/`suffix` are announced via `aria-valuetext` (e.g. "$12.00"); `hint` and `errorMessage` are linked with `aria-describedby`, and `errorMessage` sets `aria-invalid`.
- The +/– buttons are named after the field (`label`, else `ariaLabel`), so several steppers on one page are told apart.
- `ariaDescribedby` appends ids owned by a parent to the field's own description, and `invalid` marks the value wrong when the parent renders the reason. The border follows `aria-invalid` either way, so the visual and the announced state never disagree.
- Implements ControlValueAccessor — works with `[(ngModel)]` and reactive forms; `formControl.disable()` disables the input and buttons.
Feedback on Number Input
Spotted a gap in this page, or something that didn't work as described? Comments are GitHub Discussions in the public styloviz/community repo, so posting needs a GitHub account.