Skip to content

NumberField — the cross-framework headless numeric stepper

NumberField is a headless, fully-accessible numeric input / spinbutton with no third-party engine behind it. It covers the whole behaviour surface: typing with locale-aware parse/format, clamping to [min, max], step snapping, +/- steppers with press-and-hold acceleration, keyboard control (ArrowUp/Down, PageUp/Down, Home/End), optional scrub-on-drag, and role="spinbutton" with the full aria-value* set. The same component ships for React, Vue, Svelte, Angular, Solid, and Lit.

The foundation is the platform itself: a native <input> for text entry, browser focus, the keyboard, and Intl.NumberFormat for locale-aware display. The numeric value is modelValue (the sole model: true prop), typed number | null; null is the empty field. The one piece of local state is the edit buffer (text): a half-typed entry like "1." or "-" is not yet a valid number, so it is held as text while the field is focused and parsed back to a number on blur / Enter. Rozie owns the author-side API: the two-way r-model:modelValue, the clamp/snap math, the keyboard choreography, the press-hold ramp, and the token-themed skin.

Every visual value is a CSS custom property, so the field re-skins to any design system, with ready-made bridges for shadcn/ui, Material 3, and Bootstrap 5.

The @rozie-ui/number-field packages

NumberField ships as six pre-compiled, per-framework packages. Install the one for your framework; there is no build step and no Rozie toolchain to set up:

PackageInstallREADME
@rozie-ui/number-field-reactnpm i @rozie-ui/number-field-reactreact/README
@rozie-ui/number-field-vuenpm i @rozie-ui/number-field-vuevue/README
@rozie-ui/number-field-sveltenpm i @rozie-ui/number-field-sveltesvelte/README
@rozie-ui/number-field-angularnpm i @rozie-ui/number-field-angularangular/README
@rozie-ui/number-field-solidnpm i @rozie-ui/number-field-solidsolid/README
@rozie-ui/number-field-litnpm i @rozie-ui/number-field-litlit/README

Each package carries only its framework peer (react + react-dom, vue, svelte, @angular/core + @angular/common + @angular/forms, solid-js, or lit + @lit-labs/preact-signals + @preact/signals-core).

Quick start

Two-way bind modelValue and set min / max / step to get a clamped, step-snapped stepper. The value is always clamped + snapped on commit; @change fires on every committed change:

rozie
<components>
{
  NumberField: './NumberField.rozie',
}
</components>

<data>
{
  qty: 1,
}
</data>

<template>
  <!-- 0..10 integer quantity -->
  <NumberField r-model:modelValue="$data.qty" :min="0" :max="10" :step="1" ariaLabel="Quantity" @change="onChange" />

  <!-- locale-aware currency -->
  <NumberField r-model:modelValue="$data.qty" :min="0" :step="0.01" :formatOptions="{ style: 'currency', currency: 'USD' }" ariaLabel="Price" />
</template>

r-model:modelValue is Rozie's two-way bind: the consumer hands NumberField a number | null, NumberField writes the new clamped + snapped value back on every commit, and the framework reconciler picks it up with no onChange → setState wiring. Because modelValue is the component's sole model: true prop, the Angular output additionally implements ControlValueAccessor, so a NumberField is a form control ([formControl] / [(ngModel)] bind directly).

API

Props

NameTypeDefaultRuntime-updatable?Description
modelValueNumber | nullnullyes (via r-model)The numeric value — the sole model: true prop, so Angular emits a ControlValueAccessor. null is the empty field. Clamped to [min, max] and snapped to step on every commit.
minNumber | nullnullyesInclusive lower bound. Every commit clamps >= min; Home jumps to min. null = no lower bound. Emitted as aria-valuemin.
maxNumber | nullnullyesInclusive upper bound. Every commit clamps <= max; End jumps to max. null = no upper bound. Emitted as aria-valuemax.
stepNumber1yesIncrement/decrement granularity. Arrow keys + the +/- buttons step by step; commits snap to the nearest multiple of step from min (or 0).
largeStepNumber10yesCoarse step applied by PageUp / PageDown.
formatOptionsObject{}yesForwarded to Intl.NumberFormat for locale-aware display (e.g. { style: 'currency', currency: 'USD' }). Stripped back off on commit.
allowScrubBooleanfalseyesOpt in to scrub-on-drag — drag horizontally to change the value by step per few pixels.
disabledBooleanfalseyesDisable the whole control (also sets the Angular CVA disabled state).
readonlyBooleanfalseyesShow + focus the value but block all edits.
ariaLabelStringnullyesAccessible name applied to the role="spinbutton" input (aria-label).

Events

EventDescription
changeFired on every committed change — a typed value committed on blur/Enter, a step from the +/- buttons or the keyboard, a Home/End jump, a scrub, or a programmatic increment/decrement/clear. Payload { value } — the new clamped + snapped number, or null when empty.

Imperative handle

Declared once in the source via $expose; obtained through each framework's native ref mechanism.

MethodDescription
focusMove DOM focus to the input and select its text. Deliberately named focus, overriding the inherited HTMLElement.focus on the Lit custom element; the override is intentional, and the compiler accepts it with a warning. This mirrors the slider/otp precedent.
incrementStep the value up by one step (clamped + snapped). Emits change.
decrementStep the value down by one step (clamped + snapped). Emits change.
clearSet the value to null (empty) and clear the edit buffer. Emits change.

Theming

Every cosmetic value the component renders is a --rozie-number-field-* CSS custom property with a built-in fallback, so it works with zero configuration and remains fully re-skinnable. Override tokens at any ancestor scope:

css
.rozie-number-field {
  --rozie-number-field-radius: 0.5rem;
  --rozie-number-field-width: 4.5rem;
  --rozie-number-field-border-color: rgba(0, 0, 0, 0.25);
  --rozie-number-field-btn-bg: rgba(0, 0, 0, 0.04);
}

Only cosmetic values flow through tokens; the structural rules (the inline-flex stepper row, the input box model, the +/- buttons) compile per-leaf and are not consumer-overridable.

The complete token table and the design-system bridges live on the dedicated theming page.

Accessibility

The input carries role="spinbutton" with aria-valuemin / aria-valuemax (when min / max are set), aria-valuenow (the current number, omitted when empty), and aria-valuetext (the locale-formatted display). Set ariaLabel (or wire an external <label>) so the control is announced. The +/- buttons are tabindex="-1" and aria-labelled so the keyboard story lives entirely on the focused input (Arrow / PageUp·Down / Home / End), matching the WAI-ARIA spinbutton pattern.

Pre-1.0 — APIs may change between minor versions.