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-react 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:
Why beforeinput bypasses React's synthetic event system
React's event system uses delegation: a single listener at the root container fires handlers during the bubbling phase. For most events this is fine. For beforeinput it is fatal - by the time bubbling reaches the root, the browser has already committed the character to the DOM. Calling e.nativeEvent.preventDefault() at that point is a no-op.
This was verified empirically: switching to React's onBeforeInput prop caused all validation and sanitisation to silently fail - any character could be typed. ProseMirror, Slate, and Lexical hit the same constraint and all use native addEventListener for beforeinput.
The listener is registered once at mount on the actual element. Mutable options are read through refs updated via useLayoutEffect so prop changes never need a listener re-registration:
useEffect(() => {
const input = internalInputRef.current
if (!input) return
const handler = (e: InputEvent) => {
if (e.inputType === 'insertFromPaste' || e.inputType === 'insertFromDrop') return
const result = handleNumoraOnBeforeInput(e, {
decimalMaxLength: maxDecimalsRef.current,
formattingOptions: formattingOptionsRef.current,
})
if (result !== null) {
const numInput = input as NumoraHTMLInputElement
numInput.formattedValue = result.value
// Call onChange directly - guaranteed delivery, no dependency on
// React's value-tracker diff or synthetic event pipeline.
onChangeRef.current?.(createSyntheticChangeEvent(numInput, result.rawValue ?? ''))
// Dispatch a native 'input' to keep React's value tracker in sync.
isHandledByBeforeInputRef.current = true
input.dispatchEvent(new Event('input', { bubbles: true }))
isHandledByBeforeInputRef.current = false
}
}
input.addEventListener('beforeinput', handler)
return () => input.removeEventListener('beforeinput', handler)
}, [])isHandledByBeforeInputRef - preventing double-fire
The native handler calls onChange directly with a synthetic ChangeEvent, then dispatches a real input event so React's value tracker stays in sync. That dispatched event flows through React's normal pipeline and would trigger the React-side handleChange → onChange a second time, firing every callback twice per keystroke.
isHandledByBeforeInputRef is the gate: it is set to true synchronously around dispatchEvent, and because dispatchEvent is synchronous, the React-side handler runs to completion while the flag is up. It reads the flag, sees that onChange has already been called, and exits without re-emitting.
defaultValue vs value
NumoraInput renders the underlying <input> with defaultValue, not value:
<input defaultValue={initialDisplayValue} ref={internalInputRef} />A controlled React input (value=) makes the reconciler overwrite input.value on every render - undoing what setRangeText just wrote and breaking both formatting and undo history. defaultValue tells React "set the DOM value once at mount, then leave it alone," leaving setRangeText as the sole mutation path during typing.
When you pass a value prop, an effect syncs it directly into the DOM without firing events - programmatic value changes don't need undo history or cursor management.
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:
The Proxy on e.target
Every formatting cycle produces two strings: a formatted display value and a raw numeric value. The vanilla NumoraInput class emits one or the other through onChange based on rawValueMode. The React component does something different: it always exposes both on the change event's target.
It does this with a Proxy. The synthetic ChangeEvent's target wraps the real HTMLInputElement - reads to target.value are intercepted and return the raw string; target.formattedValue reads through to a custom property the library writes after each format pass; everything else (selectionStart, focus(), name, …) passes through to the underlying element.
import { type NumoraHTMLInputElement, type NumoraInputChangeEvent } from 'numora-react'
// From any onChange handler:
onChange={(e: NumoraInputChangeEvent) => {
e.target.value // → "1234.56" (raw, via Proxy)
e.target.formattedValue // → "1,234.56" (display string)
e.target.selectionStart // → caret position (real HTMLInputElement)
}}
// Or off a forwarded ref:
const ref = useRef<NumoraHTMLInputElement>(null)
ref.current?.formattedValue // → "1,234.56"
ref.current?.value // → "1,234.56" (DOM value, formatted - no Proxy here)Note: the Proxy only wraps e.target on change events. A forwarded ref points at the real DOM element, so ref.current.value returns the formatted string (because that's what's in the DOM input). Use ref.current.formattedValue when reading from a ref.
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.
In numora-react, both are exposed on the change event's target.e.target.value returns the raw value via a Proxy; e.target.formattedValue is the display string. The same target is also a real HTMLInputElement, so selectionStart and friends keep working:
import { NumoraInput, type NumoraInputChangeEvent } from 'numora-react'
<NumoraInput
thousandSeparator=","
maxDecimals={2}
onChange={(e: NumoraInputChangeEvent) => {
e.target.value // → "1234.56" (raw, no separators)
e.target.formattedValue // → "1,234.56" (display string)
e.target.selectionStart // → caret position
}}
/>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 numora-react numoraAnd paste a messy payload like "$1,234.56abc" into this:
import { NumoraInput, FormatOn, ThousandStyle } from 'numora-react'
<NumoraInput
formatOn={FormatOn.Change}
thousandSeparator=","
thousandStyle={ThousandStyle.Thousand}
maxDecimals={2}
enableCompactNotation
onChange={(e) => console.log(e.target.value)}
/>The full options reference lives on the installation page, and every individual feature has its own deep-dive in the sidebar.