XGitHub

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 via setSelectionRange after 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 sets inputmode="decimal" for the mobile numeric pad and generates a pattern regex via getNumoraPattern so the browser's form-validation rules match Numora's runtime rules.
value: "1234.56"
selectionStart: 0
selectionEnd: 0
type: "text"
inputMode: "decimal" → mobile numeric pad
pattern: "[0-9.,-]*" → form-validation regex

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:

text
keydown  →  beforeinput  →  [DOM mutation]  →  input  →  keyup
  • keydown (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 an inputType describing the semantic action ("insertText", "deleteContentBackward", …) and a data field 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:

// event log - start typing

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:

JavaScript Number
> 0.1 + 0.2
0.30000000000000004

> typeof (0.1 + 0.2)
"number"

> (0.1 + 0.2) === 0.3
false
Numora (strings only)
> input.value
"0.3"

> typeof input.value
"string"

> input.value === "0.3"
true

On 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 inputNumora interpretation
"$1,234.56""1234.56"
"1.5e-7""0.00000015"
"1k""1000"
"2.5M""2500000"
"007""7"
"1.2.3""1.23"
"1 234""1234"
"abc123def""123"
"12,34,567""1234567"

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:

Step
Function
Output
0
filterMobileKeyboardArtifacts
Strip non-breaking spaces and Unicode whitespace
"$1,234.56abc"
1
removeThousandSeparators
Strip the configured grouping char (formatting, not data)
"$1234.56abc"
2
expandCompactNotation
Expand "1k" → "1000", "2.5M" → "2500000"
"$1234.56abc"
3
expandScientificNotation
Expand "1.5e-7" → "0.00000015"
"$1234.56abc"
4
removeNonNumericCharacters
Strip everything that is not a digit or the decimal separator
"1234.56"
5
removeExtraDecimalSeparators
Keep only the first decimal point
"1234.56"
6
removeLeadingZeros
Drop "007" → "7" unless leading zeros are enabled
"1234.56"

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:

Plain <input type="text">
Accepts every character. No grouping. No expansion. Holds a string and shrugs.
NumoraInput
Strips junk, expands compact notation, applies thousand grouping.

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 , in 1,234 and you press Backspace, Numora moves the selection past the separator so the digit 4 gets deleted, not the comma.

That's it. Decimal-separator handling and character insertion both moved to beforeinput for two reasons:

  1. e.key on keydown reports 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.
  2. keydown fires before the browser has decided what the input will be. Cancelling it with preventDefault is 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:

  1. It is cancelable.
  2. When you write the formatted value with setRangeText after cancelling, the browser records a single atomic undo entry - Ctrl+Z restores the previous value perfectly.
  3. It fires for every input vector: keyboard, voice dictation, drag-and-drop, virtual keyboards, autocorrect, IME - even when keydown doesn't fire at all.

The inputType values Numora dispatches on:

ValueTriggered byNumora action
insertTextTypingFormat and apply via setRangeText
deleteContentBackwardBackspaceCompute deletion, format, apply
deleteContentForwardDeleteCompute deletion, format, apply
deleteByCutCtrl+XRemove selection, format, apply
insertFromPasteCtrl+VDefer to dedicated paste handler
historyUndo / historyRedoCtrl+Z / Ctrl+YPass through; let the browser restore

The handler runs in nine steps:

text
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:

tsx
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:

tsx
<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:

FormatOn.Change
Best for live previews and read-while-typing UX. Slightly more reformatting per keystroke.
FormatOn.Blur
Best when users are editing existing values and shouldn't see separators move under the cursor.

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:

ThousandStyle.Thousand
e.g. 1,000,000
Western convention. Group by three digits from the right.
ThousandStyle.Lakh
e.g. 10,00,000
Indian convention. Three on the right, then groups of two.
ThousandStyle.Wan
e.g. 100,0000
CJK convention. Four-digit groups (myriad / 万).

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:

Naive formatter (rebuilds the string)
Reformats on every change. Cursor is restored to its old index, but the index now points at a different character.
NumoraInput
Counts meaningful digits before the caret, then re-anchors. The cursor stays between the same two digits.

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:

  1. e.preventDefault() - block the native paste outright.
  2. Read e.clipboardData.getData('text/plain').
  3. Splice the clipboard text into the current value at the active selection.
  4. 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.
  5. Set input.value directly and reposition the cursor.
  6. Dispatch a synthetic input event so consumers see onChange.

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.

tsx
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:

tsx
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:

e.target.value // raw, no separators
""
e.target.formattedValue // display string
""
e.target.selectionStart
0

Drop-in usage

Everything above is one component (or one class) and a handful of options. Install:

bash
pnpm add numora-react numora

And paste a messy payload like "$1,234.56abc" into this:

tsx
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.