How a numeric input actually works.
We'll dissect the HTML <input> element, watch the four events that fire on every keystroke, see what breaks when users paste real-world numbers, and rebuild it with numora step by step.
What is an <input>, really?
Every Numora input is a real <input type="text">. There is no contenteditable div, no shadow DOM, no virtual cursor - just the platform primitive with a small, well-defined API. The whole library is a thin layer that listens to the right events on this element and writes back to it.
The four properties Numora reads and writes on every keystroke:
value- the displayed string. Numora keeps the formatted value here, so the DOM is always the source of truth.selectionStart/selectionEnd- the caret position. Read to capture where the user is; written viasetSelectionRangeafter reformatting.setRangeText(replacement, start, end, selectMode)- the secret weapon. Replaces a range in a single atomic mutation that the browser records as one undo entry.inputMode+pattern- Numora setsinputmode="decimal"for the mobile numeric pad and generates apatternregex viagetNumoraPatternso the browser's form-validation rules match Numora's runtime rules.
The event pipeline
A single keystroke fires four events on a focused <input> in a fixed order. Each one was added at a different moment in the platform's history, for a different reason:
keydown → beforeinput → [DOM mutation] → input → keyupkeydown(1996) - fires first, before anything else. Reports the physical key, not the resulting character. Cancelable, but cancelling it is a blunt instrument.beforeinput(2016) - fires after the browser has decided what the input will be, but before it touches the DOM. Cancelable. Carries aninputTypedescribing the semantic action ("insertText", "deleteContentBackward", …) and adatafield with the resolved character. The correct hook for input formatting.input(2009) - fires after the value has changed. Not cancelable. The universal "value changed" signal - fires for keyboard, paste, drag-and-drop, voice input, autocorrect, and programmatic changes alike.keyup(1996) - fires when the key is released. Comes after the value has already mutated. Numora ignores it.
Type below to see all four fire in order, with their inputType and the resolved data:
Why Number is the enemy
JavaScript stores all numbers as 64-bit IEEE 754 floats. The decimals you reach for in a financial UI - money, ratios, percentages - don't fit cleanly into binary fractions. Add two innocent values and you get the famous result every developer eventually meets:
> 0.1 + 0.2
0.30000000000000004
> typeof (0.1 + 0.2)
"number"
> (0.1 + 0.2) === 0.3
false> input.value
"0.3"
> typeof input.value
"string"
> input.value === "0.3"
trueOn a price field, that 0.30000000000000004 isn't a rounding bug - it is a corruption of user intent. Numora's central design rule is that every value flowing through the input is a string. We never call parseFloat, never coerce with +, never store a Number anywhere in the pipeline. The string the user typed is the string the consumer reads back.
What users actually type
The usual fix is <input type="text"> plus a regex like value.replace(/[^0-9.]/g, ''). It catches the obvious noise (letters, currency symbols) and silently corrupts everything else:
| User input | Numora interpretation | What happened |
|---|---|---|
| "$1,234.56" | "1234.56" | Currency symbol + commas |
| "1.5e-7" | "0.00000015" | Scientific notation |
| "1k" | "1000" | Compact (k) |
| "2.5M" | "2500000" | Compact (M) |
| "007" | "7" | Leading zeros |
| "1.2.3" | "1.23" | Multiple decimals |
| "1 234" | "1234" | Non-breaking space (mobile keyboards) |
| "abc123def" | "123" | Letters mixed in |
| "12,34,567" | "1234567" | Indian Lakh grouping |
A character-class regex can't tell that 1.5e-7 is a number, that 1k means 1000, or that German 1.234,56 is a hundred times larger than 1.23456. Numora's seven-step pipeline handles every row above - that's what's next.
The sanitization pipeline
Every value Numora touches - typed character, pasted clipboard, programmatic write - passes through a single pipeline of seven pure functions. Each one has one job and runs in a fixed order; the output of step N is the input of step N+1. The whole pipeline runs on every keystroke.
Type below and watch the value transform step by step. Greyed-out rows are steps that didn't change the value:
For the deeper write-up of each function and why the order matters, see How It Works.
Plain <input> vs Numora
Type the same thing into both inputs below - for example $1,234.56abc, then 1.5e-7, then 2.5k. The plain input takes whatever you give it. Numora cleans, expands, and groups in real time:
keydown stays out of the way
Most input-mask libraries put their formatting logic in keydown because it's the oldest hook available. Numora deliberately does the opposite. Thekeydown handler does one thing:
- Skip the cursor over thousand separators on Backspace and Delete. When the cursor sits next to a
,in1,234and you press Backspace, Numora moves the selection past the separator so the digit4gets deleted, not the comma.
That's it. Decimal-separator handling and character insertion both moved to beforeinput for two reasons:
e.keyonkeydownreports the physical key, not the resolved character - which breaks under non-Latin keyboards, dead keys, and IME composition. The same key on a Polish keyboard produces different text depending on modifiers.keydownfires before the browser has decided what the input will be. Cancelling it withpreventDefaultis a blunt instrument that can disable accessibility features and screen readers.
beforeinput sidesteps both problems by operating on the resolved character and the semantic action. So that's where the real work happens.
beforeinput + undo/redo
beforeinput was added to the DOM spec in 2016 specifically because developers had been abusing keydown for input manipulation for two decades - breaking undo history, IME input, and accessibility along the way. Numora's primary path runs entirely through beforeinput for three reasons:
- It is cancelable.
- When you write the formatted value with
setRangeTextafter cancelling, the browser records a single atomic undo entry - Ctrl+Z restores the previous value perfectly. - It fires for every input vector: keyboard, voice dictation, drag-and-drop, virtual keyboards, autocorrect, IME - even when
keydowndoesn't fire at all.
The inputType values Numora dispatches on:
| Value | Triggered by | Numora action |
|---|---|---|
insertText | Typing | Format and apply via setRangeText |
deleteContentBackward | Backspace | Compute deletion, format, apply |
deleteContentForward | Delete | Compute deletion, format, apply |
deleteByCut | Ctrl+X | Remove selection, format, apply |
insertFromPaste | Ctrl+V | Defer to dedicated paste handler |
historyUndo / historyRedo | Ctrl+Z / Ctrl+Y | Pass through; let the browser restore |
The handler runs in nine steps:
1. Early return for paste/drop → dedicated paste handler takes over
2. Read currentValue, selectionStart/End from the DOM
3. Special-case decimal separator → convert ',' or '.' to configured sep,
block second decimal if one exists
4. Compute intendedValue per inputType:
insertText → splice data at cursor
deleteContentBackward → remove char before cursor (or selection)
deleteContentForward → remove char after cursor (or selection)
deleteByCut/Drag → remove selection
default → return null (browser handles natively)
5. e.preventDefault() → block the browser mutation
6. formatInputValue(intendedValue) → sanitize → trim → apply separators
7. target.setRangeText(formatted, 0, currentValue.length, 'end')
→ atomic replace, single undo entry
8. updateCursorPosition(...) → restore caret based on digit count
9. Return { formatted, raw }Try it: type 1234567 below, then press Ctrl+Z. The browser restores both the digit and the comma in a single undo step:
Format on change vs format on blur
Numora applies thousand separators in one of two modes. FormatOn.Change groups every keystroke; the user sees commas appear as they type. FormatOn.Blur groups only when the input loses focus; while editing, the value reads as a plain unformatted string. Both modes use the same pipeline - only the moment of formatting differs.
Type 1234567.89 in both inputs to feel the difference:
Thousand grouping styles
Different writing systems group large numbers differently. Numora ships three styles, each producing a different visual rhythm for the same underlying value. Type 10000000 in all three to see them diverge:
Cursor preservation
The classic input-mask bug: a user clicks into the middle of 1,234,567 to insert a digit, the formatter rebuilds the string with a new comma in a different place, and the cursor lands several characters away from where it should be.
Numora's updateCursorPosition anchors on meaningful digits rather than character index. countMeaningfulDigitsBeforePosition walks the intended string from the start to the caret, counting only digits and the decimal separator while skipping thousand separators. It then walks the formatted string until it has counted the same number, and parks the cursor there. The user feels the caret stay "between the same two digits" no matter how the separators rearranged around it.
Click between the 3 and the 4 in both inputs below and type a digit. The naive formatter jumps your cursor; Numora keeps it pinned:
Paste from a foreign locale
When the user pastes, beforeinput fires first with inputType: 'insertFromPaste'. Numora returns immediately - the dedicated paste handler takes over because it has access to the full ClipboardData object.
The paste handler:
e.preventDefault()- block the native paste outright.- Read
e.clipboardData.getData('text/plain'). - Splice the clipboard text into the current value at the active selection.
- Run the combined value through the full pipeline with
shouldRemoveThousandSeparators: true- unconditionally, because clipboard text may carry separators from a foreign locale (German"1.234,56") that would otherwise be parsed as decimals. - Set
input.valuedirectly and reposition the cursor. - Dispatch a synthetic
inputevent so consumers seeonChange.
Copy any of these strings and paste into the Numora input below:
Raw value vs formatted value
Every formatting cycle produces two strings: the formatted display string ("1,234.56") and the raw numeric string ("1234.56"). The raw value is what your form, your validator, and your API expect; the formatted value is what the user sees.
The vanilla NumoraInput class emits one or the other through onChange, controlled by rawValueMode:
import { NumoraInput } from 'numora'
new NumoraInput(container, {
thousandSeparator: ',',
decimalMaxLength: 2,
rawValueMode: true,
// When rawValueMode is true, onChange receives the raw value.
// When false (the default), it receives the formatted value.
onChange: (value) => console.log(value),
})Type below - both readouts update on every keystroke:
""
""
0
Drop-in usage
Everything above is one component (or one class) and a handful of options. Install:
pnpm add numoraAnd paste a messy payload like "$1,234.56abc" into this:
import { NumoraInput, FormatOn, ThousandStyle } from 'numora'
new NumoraInput(container, {
formatOn: FormatOn.Change,
thousandSeparator: ',',
thousandStyle: ThousandStyle.Thousand,
decimalMaxLength: 2,
enableCompactNotation: true,
onChange: (value) => console.log(value),
})The full options reference lives on the installation page, and every individual feature has its own deep-dive in the sidebar.