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.
'$' prefix, 2 decimals. Blur or press Enter to commit — the value is a plain number, never '$1,234.50'.
new NumberInput('#price', { ariaLabel: 'Price in dollars',
prefix: '$', precision: 2, min: 0
})
Integer steps with the +/− buttons — hold one down: 500 ms delay, 60 ms repeat, ×10 after 2 s.
new NumberInput('#qty', { ariaLabel: 'Quantity',
value: 1, min: 0, max: 500, step: 1
})
A ' kg' suffix and step 0.5 — precision is inferred from the step (one decimal).
new NumberInput('#weight', { ariaLabel: 'Weight in kilograms',
suffix: ' kg', step: 0.5, min: 0,
placeholder: '0.0'
})
Decimal comma, dot thousands: type 1234567,89 — or paste '1.234,56'.
new NumberInput('#euro', { ariaLabel: 'Amount in euros',
decimal: ',', thousands: '.',
precision: 2, suffix: ' €'
})
10–100. Type 500 — the border warns while typing, the commit clamps
it. Empty commits to null, not 0.
new NumberInput('#clamp', { ariaLabel: 'Percentage',
min: 10, max: 100, value: 50
})
With name, a hidden input carries the raw value — the
server never sees the formatting.
new NumberInput('#amount', { ariaLabel: 'Payment amount',
name: 'amount', prefix: '$',
precision: 2, value: 1234.5
})
// → amount=1234.5
The complete NumberInput API — constructor modes, every option,
instance methods, pure static helpers, declarative attributes, theming and
accessibility.
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> is
enhanced in place: its type is coerced to text with
inputmode="decimal", and everything is restored by
destroy(). A name on the original input migrates to
the hidden raw-value carrier for the widget's lifetime (and back on
destroy()), so the server never sees the formatted text.value option for the initial value and set
ariaLabel (no external <label> can point at
the generated input).<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.
Every key of NumberInput.defaults, with its default value.
Options are merged over the defaults; undefined values are ignored.
| Name | Type | Default | Description |
|---|---|---|---|
min | number | null | null |
Lower clamp bound, applied on commit. A min ≥ 0 also auto-disables negative input. |
max | number | null | null |
Upper clamp bound, applied on commit. |
step | number | 1 |
Increment for ArrowUp/ArrowDown and the stepper buttons. Shift+Arrow steps by 10 × step. |
precision | number | null | null |
Decimal places used on commit. When null, inferred from step's decimals (1 → 0, 0.5 → 1). Clamped to 0–20. |
thousands | string | false | ',' |
Grouping separator: ',' | '.' | ' ' | false (none). Silently swapped to the other separator when it would collide with decimal. |
decimal | string | '.' |
'.' or ',' — must differ from thousands. Both the . and , keys insert the configured separator. |
prefix | string | '' |
Non-editable adornment before the text, e.g. '$'. Never part of the value. |
suffix | string | '' |
Non-editable adornment after the text, e.g. ' kg'. |
steppers | boolean | true |
Show the +/− buttons. Hold to repeat: 500 ms delay, then every 60 ms, ×10 speed after 2 s. |
allowNegative | boolean | true |
Permit a leading minus. Auto-disabled when min ≥ 0. |
placeholder | string | null | null |
Placeholder text for the empty input. |
ariaLabel | string | null | null |
Accessible name for the input — required in container mode, where no <label> can point inside the widget. |
value | number | null | null |
Initial value (container mode; in enhance mode the input's own value seeds the widget). |
name | string | null | null |
Adds a hidden input carrying the RAW numeric value for form submission (e.g. amount=1234.5, never '$1,234.50'). |
disabled | boolean | false |
Start disabled. Toggle later with enable() / disable(). |
theme | string | 'auto' |
'auto' | 'light' | 'dark'. Auto re-resolves live against the page theme. |
styles | boolean | true |
false = headless: no CSS is injected — style the .vnum-* markup yourself. |
labels | object | { increment: 'Increase value', decrement: 'Decrease value' } |
Accessible labels for the stepper buttons (i18n). Merged key-by-key over the defaults. |
onChange | function | null | null |
fn(value | null) — the committed value, fired on blur, step or Enter. Empty input commits null, not 0. |
onInput | function | null | null |
fn(value | null) — the live value while typing. |
| Method | Returns | Description |
|---|---|---|
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). |
| Static | Description |
|---|---|
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'. |
opts for parse/formatBoth helpers normalize the same formatting-relevant subset of the constructor options. All keys are optional:
| Name | Type | Default | Description |
|---|---|---|---|
precision | number | null | null |
Decimals for format; when null, inferred from step's decimals, otherwise the number's natural decimals. |
step | number | null | null |
Only used to infer precision when that is not set. |
decimal | string | '.' |
'.' or ',' — anything else falls back to '.'. |
thousands | string | false | ',' |
',' | '.' | ' ' | false; swapped when it collides with decimal. |
prefix / suffix | string | '' |
Appended around format output; stripped as junk by parse. |
allowNegative | boolean | true |
When false (or min ≥ 0) a leading minus is ignored by parse. |
min / max | number | null | null |
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'
NumberInput reports through the two option callbacks (it dispatches no custom DOM events):
onChange(value | null) — the committed value: parse → clamp
to min/max → round to precision. Fires
on blur, Enter, arrow-key steps and the stepper buttons.onInput(value | null) — the live value on every keystroke,
before any clamping.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.
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">
| Attribute | Maps to | Notes |
|---|---|---|
data-vnum | — | Marks the element; an optional numeric value (data-vnum="42") is a shorthand for value. |
data-min / data-max / data-step / data-precision | min/max/step/precision | Numbers. |
data-thousands | thousands | Separator character, or the keywords space / none (false also disables grouping). |
data-decimal | decimal | . or , |
data-prefix / data-suffix | prefix/suffix | Strings. |
data-steppers / data-allow-negative / data-disabled / data-styles | steppers/allowNegative/disabled/styles | Booleans; "false" and "0" are false. |
data-name / data-placeholder / data-value / data-theme | name/placeholder/value/theme | As the options. |
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
| Key | Action |
|---|---|
| ArrowUp / ArrowDown | ± step (commits) |
| Shift + Arrow | ± 10 × step |
| Enter | Commit (then the form may submit — the hidden field is already synced) |
| Esc | Revert 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.
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.
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
})
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>