Vanilla UI Kit NumberInput v1.0.0 · toast →

Numbers, one file.

A formatted numeric input with live thousands grouping, clamping, precision rounding and hold-to-repeat steppers. Try the arrow keys (Shift for ×10), Enter to commit, Esc to revert — every card is live.

Currency

'$' prefix, 2 decimals. Blur or press Enter to commit — the value is a plain number, never '$1,234.50'.

value: —
new NumberInput('#price', { ariaLabel: 'Price in dollars',
  prefix: '$', precision: 2, min: 0
})

Quantity

Integer steps with the +/− buttons — hold one down: 500 ms delay, 60 ms repeat, ×10 after 2 s.

value: —
new NumberInput('#qty', { ariaLabel: 'Quantity',
  value: 1, min: 0, max: 500, step: 1
})

Weight

A ' kg' suffix and step 0.5 — precision is inferred from the step (one decimal).

value: —
new NumberInput('#weight', { ariaLabel: 'Weight in kilograms',
  suffix: ' kg', step: 0.5, min: 0,
  placeholder: '0.0'
})

European format

Decimal comma, dot thousands: type 1234567,89 — or paste '1.234,56'.

value: —
new NumberInput('#euro', { ariaLabel: 'Amount in euros',
  decimal: ',', thousands: '.',
  precision: 2, suffix: ' €'
})

Min/max clamping

10–100. Type 500 — the border warns while typing, the commit clamps it. Empty commits to null, not 0.

value: —
new NumberInput('#clamp', { ariaLabel: 'Percentage',
  min: 10, max: 100, value: 50
})

In a real form

With name, a hidden input carries the raw value — the server never sees the formatting.

submits: —
new NumberInput('#amount', { ariaLabel: 'Payment amount',
  name: 'amount', prefix: '$',
  precision: 2, value: 1234.5
})
// → amount=1234.5

API reference

The complete NumberInput API — constructor modes, every option, instance methods, pure static helpers, declarative attributes, theming and accessibility.

Constructor & usage

new NumberInput(target, options?)   // → instance
NumberInput.create(target, options?) // same as new

target is a selector string or an element, in one of two modes:

<input id="price" value="1234.5">
new NumberInput('#price', { prefix: '$', precision: 2, min: 0 })

<div id="amount"></div>
new NumberInput('#amount', { ariaLabel: 'Amount', value: 1234.5, name: 'amount' })

Constructing on an element that already has an instance? Use NumberInput.get(el) to retrieve the existing one. SSR-safe: without a DOM the constructor is a no-op and the pure helpers still work in Node.

Options

Every key of NumberInput.defaults, with its default value. Options are merged over the defaults; undefined values are ignored.

NameTypeDefaultDescription
minnumber | nullnull Lower clamp bound, applied on commit. A min ≥ 0 also auto-disables negative input.
maxnumber | nullnull Upper clamp bound, applied on commit.
stepnumber1 Increment for ArrowUp/ArrowDown and the stepper buttons. Shift+Arrow steps by 10 × step.
precisionnumber | nullnull Decimal places used on commit. When null, inferred from step's decimals (1 → 0, 0.5 → 1). Clamped to 0–20.
thousandsstring | false',' Grouping separator: ',' | '.' | ' ' | false (none). Silently swapped to the other separator when it would collide with decimal.
decimalstring'.' '.' or ',' — must differ from thousands. Both the . and , keys insert the configured separator.
prefixstring'' Non-editable adornment before the text, e.g. '$'. Never part of the value.
suffixstring'' Non-editable adornment after the text, e.g. ' kg'.
steppersbooleantrue Show the +/− buttons. Hold to repeat: 500 ms delay, then every 60 ms, ×10 speed after 2 s.
allowNegativebooleantrue Permit a leading minus. Auto-disabled when min ≥ 0.
placeholderstring | nullnull Placeholder text for the empty input.
ariaLabelstring | nullnull Accessible name for the input — required in container mode, where no <label> can point inside the widget.
valuenumber | nullnull Initial value (container mode; in enhance mode the input's own value seeds the widget).
namestring | nullnull Adds a hidden input carrying the RAW numeric value for form submission (e.g. amount=1234.5, never '$1,234.50').
disabledbooleanfalse Start disabled. Toggle later with enable() / disable().
themestring'auto' 'auto' | 'light' | 'dark'. Auto re-resolves live against the page theme.
stylesbooleantrue false = headless: no CSS is injected — style the .vnum-* markup yourself.
labelsobject{ increment: 'Increase value', decrement: 'Decrease value' } Accessible labels for the stepper buttons (i18n). Merged key-by-key over the defaults.
onChangefunction | nullnull fn(value | null) — the committed value, fired on blur, step or Enter. Empty input commits null, not 0.
onInputfunction | nullnull fn(value | null) — the live value while typing.

Methods

MethodReturnsDescription
getValue()number | null The committed value. An empty input is null, not 0.
setValue(v, {silent}?)instance Accepts a number or a string ('1.234,5' parses with the instance's separators). Clamps to min/max and rounds to precision. {silent: true} skips onChange.
stepUp() / stepDown()instance ± one step, committed (fires onChange on a real change).
enable() / disable()instance Toggle the input and stepper buttons.
focus()instance Focus the inner text input.
destroy()instance Remove the widget. In enhance mode the original input is restored exactly as found, carrying the RAW committed value in .value (and its original name back).

Statics & helpers

StaticDescription
NumberInput.parse(str, opts?) Pure, DOM-free. Lenient extraction: NumberInput.parse('$1,234.50') → 1234.5; null when the string has no digits. Never clamps, never rounds.
NumberInput.format(num, opts?) Pure, DOM-free. NumberInput.format(1234.5, { precision: 2 }) → '1,234.50'. A string first goes through parse. Output includes prefix/suffix when given; null/non-finite input formats to ''.
NumberInput.get(el) The live instance bound to an element (or null).
NumberInput.create(target, opts) Alias of new NumberInput(target, opts).
NumberInput.autoInit(root?) Initialize every [data-vnum] under root (default: document); returns the created instances. Runs automatically on DOMContentLoaded.
NumberInput.defaults The live defaults object — mutate before constructing (e.g. NumberInput.defaults.styles = false).
NumberInput.css The full stylesheet as a string (rendered with the current salt) — a starting point for headless styling.
NumberInput.salt CSS isolation token, default 'vc1'. Set your own token or false before the first instance.
NumberInput.version Version string, '1.0.0'.

FormatOptions — the opts for parse/format

Both helpers normalize the same formatting-relevant subset of the constructor options. All keys are optional:

NameTypeDefaultDescription
precisionnumber | nullnull Decimals for format; when null, inferred from step's decimals, otherwise the number's natural decimals.
stepnumber | nullnull Only used to infer precision when that is not set.
decimalstring'.' '.' or ',' — anything else falls back to '.'.
thousandsstring | false',' ',' | '.' | ' ' | false; swapped when it collides with decimal.
prefix / suffixstring'' Appended around format output; stripped as junk by parse.
allowNegativebooleantrue When false (or min ≥ 0) a leading minus is ignored by parse.
min / maxnumber | nullnull Not used to clamp by the pure helpers — but min ≥ 0 disables negative parsing.
NumberInput.parse('$1,234.50')                        // → 1234.5
NumberInput.format(1234.5,  { precision: 2 })         // → '1,234.50'
NumberInput.format(1234.56, { decimal: ',', thousands: '.', precision: 2 })
                                                      // → '1.234,56'

Callbacks

NumberInput reports through the two option callbacks (it dispatches no custom DOM events):

On Enter the commit happens first and the hidden form field is already synced before a surrounding form submits. Paste is sanitized ('$1,234.50' becomes 1234.5), and out-of-range text shows a danger border while typing until the commit clamps it.

Declarative init

Add data-vnum to an <input> or container — instances are built on DOMContentLoaded (or call NumberInput.autoInit(root) after inserting markup).

<input data-vnum data-prefix="$" data-precision="2" data-min="0">
AttributeMaps toNotes
data-vnum—Marks the element; an optional numeric value (data-vnum="42") is a shorthand for value.
data-min / data-max / data-step / data-precisionmin/max/step/precisionNumbers.
data-thousandsthousandsSeparator character, or the keywords space / none (false also disables grouping).
data-decimaldecimal. or ,
data-prefix / data-suffixprefix/suffixStrings.
data-steppers / data-allow-negative / data-disabled / data-stylessteppers/allowNegative/disabled/stylesBooleans; "false" and "0" are false.
data-name / data-placeholder / data-value / data-themename/placeholder/value/themeAs the options.

Theming

Auto light/dark with the family's resolution order: <html data-theme> / data-bs-theme / .dark class → prefers-color-scheme, re-resolved live. Pin per instance with theme: 'dark'. All colors are CSS custom properties on .vnum:

.vnum {
  --vnum-accent: #b45309;  /* focus ring, active stepper */
  --vnum-bg: …; --vnum-text: …; --vnum-muted: …; --vnum-faint: …;
  --vnum-danger: …;        /* out-of-range warning border */
  --vnum-radius: 10px; --vnum-font: …; --vnum-shadow: …;
}

With the VC core loaded, VC.config({ accent: '#b45309' }) themes this and every other family component in one call. The widget renders as class="vnum vc1"; structural rules ship salted so host design systems can't override them, while the unsalted --vnum-* variable definitions keep page overrides working. Change the token with NumberInput.salt = 'acme' (or false) before the first instance.

Headless: NumberInput.defaults.styles = false (or per instance) injects no CSS at all — you keep formatting, caret math, steppers and ARIA, and style this markup contract yourself (NumberInput.css is the reference stylesheet as a string):

.vnum[data-theme=light|dark]          ← .vnum-invalid while out of range,
  .vnum-affix.vnum-prefix               .vnum-disabled when disabled
  .vnum-input                         ← role=spinbutton, inputmode=decimal
  .vnum-affix.vnum-suffix
  .vnum-steps                         ← aria-hidden
    .vnum-btn.vnum-up
    .vnum-btn.vnum-down
  input[type=hidden]                  ← raw value, when `name` given

Accessibility

KeyAction
ArrowUp / ArrowDown± step (commits)
Shift + Arrow± 10 × step
EnterCommit (then the form may submit — the hidden field is already synced)
EscRevert to the last committed value

The input is role="spinbutton" with aria-valuemin/max/now and aria-valuetext (the fully formatted text, e.g. $1,234.50). The stepper buttons are aria-hidden and out of the tab order — they duplicate the arrow keys — but stay fully clickable with hold-to-repeat. Keystrokes that can't lead to a valid number are rejected; - is allowed only when allowNegative and only leading. Reduced motion is respected.

Headless mode

With styles: false NumberInput injects no CSS at all — the full behavior (formatting, caret math, steppers), the ARIA contract and the stable .vnum-* class hooks all remain; you bring the stylesheet. The demos below each run in their own <iframe>: the styled examples above have already injected the component stylesheet into this page, so only a separate, clean document can show what headless truly looks like.

Unstyled (styles: false)

The raw markup contract — a native input with role="spinbutton", two real stepper buttons, prefix adornment. Zero CSS was injected, yet typing, arrows, steppers and Enter-to-commit all work.

new NumberInput('#demo', {
  ariaLabel: 'Amount', value: 1234.5,
  prefix: '$', precision: 2, min: 0,
  styles: false   // ← zero CSS injected
})

Bring your own design (Tailwind)

The same class hooks mapped to a deliberately different design with Tailwind utilities — solid stepper column, soft ring, its own palette. Nothing loads from the CDN until you click.

<style type="text/tailwindcss">
  .vnum        { @apply inline-flex items-stretch overflow-hidden rounded-2xl
                 bg-white ring-2 ring-emerald-200 shadow-lg shadow-emerald-100
                 focus-within:ring-emerald-500; }
  .vnum-affix  { @apply flex items-center bg-emerald-50 px-3 text-sm
                 font-bold text-emerald-600; }
  .vnum-input  { @apply w-32 border-0 bg-transparent px-3 py-2.5 text-right
                 text-base font-semibold text-slate-800 outline-none; }
  .vnum-steps  { @apply flex flex-col; }
  .vnum-btn    { @apply flex flex-1 cursor-pointer items-center justify-center
                 border-0 bg-emerald-500 px-3 text-white hover:bg-emerald-600
                 active:bg-emerald-700; }
  .vnum-down   { @apply border-t border-emerald-400; }
  .vnum.vnum-invalid  { @apply ring-rose-400; }
  .vnum.vnum-disabled { @apply opacity-40; }
</style>