Number Input

Free

Numeric input with increment/decrement controls, prefix/suffix, min/max clamping and decimal precision.

</>sv-number-input numberstepperinput
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 demo

More examples

Currency with precision

example.htmlAngular
<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).

example.htmlAngular
<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.

example.htmlAngular
<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.

example.htmlAngular
<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

NameTypeDefaultDescription
valuenumber (two-way)0Current numeric value. Use `[(value)]` for two-way binding.
minnumberNumber.NEGATIVE_INFINITYMinimum allowed value.
maxnumberNumber.POSITIVE_INFINITYMaximum allowed value.
stepnumber1Step amount for increment/decrement.
decimalsnumber0Number of decimal places.
clampOnNumberInputClamp'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.
labelstring''Label text above the input.
hintstring''Hint text below the input (hidden when `errorMessage` is set).
errorMessagestring''Error message shown below the input; sets `aria-invalid` and red styling.
serverErrorstring | { readonly message: string } | nullnullA 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.
placeholderstring'0'Placeholder text.
prefixstring''Text/symbol shown to the left of the value (e.g. '$').
suffixstring''Text/symbol shown to the right of the value (e.g. '%').
inputIdstring''Explicit id for the input. Auto-generated when omitted.
ariaLabelstring''`aria-label` for the input when there is no visible `label`.
ariaDescribedbystring''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.
invalidbooleanfalseMark 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.
namestring''`name` for native form submission.
sizeNumberInputSize'md'Size variant.
variantNumberInputVariant'default'Format preset. Provides default `prefix`/`suffix` when you don't set them: `percentage` → `'%'` suffix; `currency` → see `currency` below.
currencystring''ISO 4217 code — `'USD'`, `'EUR'`, `'JPY'`.
localestring''BCP-47 tag for the currency symbol's placement and the announced value.
presetNumberInputPreset'default'Layout preset. `quantity` renders a compact stepper with large +/– buttons, sized for comfortable tapping on product pages and cart rows.
disabledbooleanfalseDisable the input and buttons.
readonlybooleanfalseMake the input read-only (buttons still disabled).
requiredbooleanfalseMark the field as required (native `required` + `aria-required`).
autofocusbooleanfalseAutofocus the input on render.

Outputs

EventPayload typeDescription
changednumberEmits the new value after each change.
valueChangenumberEmits 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.

Loads giscus.app and GitHub — nothing is fetched until you click.