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

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:

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:

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

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

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

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